Files
estate/src/views/system/Questionnaire/components/QuestionnaireEdit.vue
2026-09-02 17:32:15 +08:00

411 lines
12 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>
<div class="edit-container">
<el-row :gutter="20">
<!-- 左侧:组件库 -->
<el-col :span="4">
<el-card class="component-panel sticky-card" shadow="hover">
<template #header>
<div class="card-header">
<span>组件库</span>
</div>
</template>
<draggable
:clone="cloneComponent"
:group="{ name: 'components', pull: 'clone', put: false }"
:list="componentList"
class="component-list"
item-key="type"
>
<template #item="{ element }">
<div class="component-item">
<el-icon><component :is="element.icon" /></el-icon>
<span>{{ element.label }}</span>
</div>
</template>
</draggable>
</el-card>
</el-col>
<!-- 中间:设计画布 -->
<el-col :span="12">
<el-card class="canvas-panel" shadow="hover">
<template #header>
<div class="card-header">
<span>设计画布</span>
</div>
</template>
<div class="question-title-area">
<el-input v-model.trim="form.title" class="title-input" maxlength="20" placeholder="请输入问卷标题" show-word-limit />
<el-input v-model.trim="form.description" maxlength="200" placeholder="请输入问卷描述(选填)" show-word-limit type="textarea" />
</div>
<draggable v-model="form.content" class="question-list" group="components" item-key="cid" @add="handleAddQuestion">
<template #item="{ element, index }">
<div :class="{ 'is-active': activeIndex === index }" class="question-item" @click="selectQuestion(index)">
<div class="question-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="question-content">
<el-tag class="question-type-tag" size="small" type="success">
{{ getComponentLabel(element.type) }}
</el-tag>
<span>Q{{ index + 1 }}. {{ element.title || '未命名题目' }}</span>
</div>
<div class="question-actions">
<el-button :icon="Delete" link type="danger" @click.stop="deleteQuestion(index)"> 删除 </el-button>
</div>
</div>
</template>
</draggable>
<el-empty v-if="form.content.length === 0" description="请从左侧拖拽组件到此处" />
</el-card>
</el-col>
<!-- 右侧:属性配置 -->
<el-col :span="8" style="position: sticky; top: 60px">
<el-card v-if="activeQuestion" class="property-panel sticky-card" shadow="hover">
<template #header>
<div class="card-header">
<span>属性配置</span>
</div>
</template>
<el-form :model="activeQuestion" :rules="rules" label-width="80px" style="position: sticky; top: 60px">
<el-form-item label="题目标题" prop="title">
<el-input v-model.trim="activeQuestion.title" maxlength="50" placeholder="请输入题目标题" />
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model.trim="activeQuestion.required" />
</el-form-item>
<!-- 单选/多选特有属性 -->
<template v-if="activeQuestion.type === 'radio' || activeQuestion.type === 'checkbox'">
<el-divider content-position="left">选项设置</el-divider>
<div v-for="(opt, idx) in activeQuestion.options" :key="idx" class="option-item">
<el-input
v-model.trim="opt.label"
:class="{ 'is-duplicate': isOptionDuplicate(idx) }"
maxlength="20"
placeholder="选项内容"
style="width: 200px; margin-right: 10px; margin-bottom: 10px"
@blur="handleOptionBlur(idx)"
/>
<span v-if="isOptionDuplicate(idx)" class="duplicate-tip">选项重复</span>
<el-button v-if="activeQuestion.options.length > 2" :icon="Delete" link type="danger" @click="activeQuestion.options.splice(idx, 1)">
删除
</el-button>
</div>
<el-button type="primary" @click="addOption">添加选项</el-button>
</template>
</el-form>
</el-card>
<el-empty v-else description="请点击选中一道题目" />
</el-col>
</el-row>
<!-- 底部操作栏 -->
<div class="footer-actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存草稿</el-button>
<el-button type="success" @click="handlePublish">保存并发布</el-button>
</div>
</div>
</template>
<script lang="ts" name="QuestionnaireEdit" setup>
import draggable from 'vuedraggable';
import { Delete, Rank, Tickets, Document, Edit } from '@element-plus/icons-vue';
import { ComponentItem, QuestionComponentItem, QuestionComponentnaireForm } from '@/api/system/Questionnaire/type';
import { ElMessage } from 'element-plus';
import { markRaw } from 'vue';
// ==================== Props & Emit ====================
const props = defineProps<{
/** 初始问卷数据(编辑时传入) */
initialData?: Partial<QuestionComponentnaireForm>;
}>();
const emit = defineEmits<{
/** 取消编辑 */
cancel: [];
/** 保存草稿 */
save: [data: QuestionComponentnaireForm];
/** 发布问卷 */
publish: [data: QuestionComponentnaireForm];
}>();
// ==================== 响应式数据 ====================
const componentList = ref<ComponentItem[]>([
{ type: 'radio', label: '单选题', icon: markRaw(Tickets) },
{ type: 'checkbox', label: '多选题', icon: markRaw(Tickets) },
{ type: 'input', label: '填空题', icon: markRaw(Edit) },
{ type: 'textarea', label: '多行文本', icon: markRaw(Document) }
]);
const form = ref<QuestionComponentnaireForm>({
id: null,
title: '',
description: '',
content: []
});
const rules = ref({
title: [{ required: true, message: '请输入问卷标题', trigger: 'blur' }]
});
const activeIndex = ref<number>(-1);
// ==================== 计算属性 ====================
const activeQuestion = computed<QuestionComponentItem | null>(() => {
if (activeIndex.value === -1) return null;
return form.value.content[activeIndex.value];
});
// ==================== 选项重复校验 ====================
/** 判断指定选项是否与其他选项内容重复(同一题内,trim 后比对) */
function isOptionDuplicate(idx: number): boolean {
const options = activeQuestion.value?.options;
if (!options) return false;
const label = options[idx]?.label?.trim();
if (!label) return false;
return options.some((opt, i) => i !== idx && opt.label?.trim() === label);
}
/** 选项失焦时弹提示 */
function handleOptionBlur(idx: number): void {
if (isOptionDuplicate(idx)) {
ElMessage.warning('选项内容不能重复');
}
}
/** 添加选项:智能生成不冲突的默认名称(避免重名) */
function addOption(): void {
const options = activeQuestion.value!.options;
let nextIndex = options.length + 1;
while (options.some((opt) => opt.label === '选项' + nextIndex)) {
nextIndex++;
}
options.push({ label: '选项' + nextIndex });
}
/** 遍历所有题目,校验选项是否重复;有重复返回错误信息,否则返回空串 */
function validateAllOptions(): string {
for (const q of form.value.content) {
if (q.type === 'radio' || q.type === 'checkbox') {
const labels = q.options.map((o) => o.label?.trim()).filter(Boolean);
if (new Set(labels).size !== labels.length) {
return `题目「${q.title || '未命名题目'}」存在重复选项`;
}
}
}
return '';
}
// ==================== 辅助方法 ====================
function getComponentLabel(type: string): string {
const item = componentList.value.find((c) => c.type === type);
return item ? item.label : '';
}
// 克隆组件(用于从左侧拖入中间)
function cloneComponent(component: ComponentItem): QuestionComponentItem {
return {
cid: Date.now(),
type: component.type,
title: '',
required: true,
options: component.type === 'radio' || component.type === 'checkbox' ? [{ label: '选项1' }, { label: '选项2' }] : []
};
}
// 选中题目
function selectQuestion(index: number): void {
activeIndex.value = index;
}
// 添加题目后的处理
function handleAddQuestion(evt: any): void {
activeIndex.value = evt.newIndex;
}
// 删除题目
function deleteQuestion(index: number): void {
form.value.content.splice(index, 1);
if (activeIndex.value === index) {
activeIndex.value = -1;
} else if (activeIndex.value > index) {
activeIndex.value--;
}
}
// ==================== 业务方法 ====================
/** 表单验证 */
function validateForm(): boolean {
if (!form.value.title.trim()) {
ElMessage.error('请输入问卷标题');
return false;
}
if (form.value.content.some((item) => !item.title?.trim())) {
ElMessage.error('请填写所有题目标题');
return false;
}
const duplicateMsg = validateAllOptions();
if (duplicateMsg) {
ElMessage.error(duplicateMsg);
return false;
}
return true;
}
/** 取消 */
function handleCancel(): void {
emit('cancel');
}
/** 保存草稿 */
function handleSave(): void {
if (!validateForm()) return;
emit('save', { ...form.value });
}
/** 发布问卷 */
function handlePublish(): void {
if (!validateForm()) return;
emit('publish', { ...form.value });
}
/** 初始化数据(编辑时调用) */
function init(data: Partial<QuestionComponentnaireForm>): void {
form.value = {
id: null,
title: '',
description: '',
content: [],
...data
};
if (typeof form.value.content === 'string') {
try {
form.value.content = JSON.parse(form.value.content);
} catch (e) {
form.value.content = [];
}
}
activeIndex.value = -1;
}
/** 重置表单 */
function reset(): void {
form.value = {
id: null,
title: '',
description: '',
content: []
};
activeIndex.value = -1;
}
// ==================== 生命周期 ====================
onMounted(() => {
if (props.initialData) {
init(props.initialData);
}
});
// ==================== 暴露方法 ====================
defineExpose({ init, reset });
</script>
<style lang="scss" scoped>
.edit-container {
position: relative;
.component-list {
.component-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 10px;
background: #f4f4f5;
border-radius: 4px;
cursor: move;
.el-icon {
margin-right: 8px;
}
}
}
.question-title-area {
margin-bottom: 20px;
padding: 10px;
border: 1px dashed #dcdfe6;
.title-input {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
}
.question-list {
min-height: 200px;
.question-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 10px;
border: 1px solid #ebeef5;
background: #fff;
border-radius: 4px;
&:hover {
border-color: #409eff;
}
&.is-active {
border-color: #409eff;
background: #ecf5ff;
}
.question-handle {
cursor: move;
padding: 0 10px;
color: #909399;
}
.question-content {
flex: 1;
.question-type-tag {
margin-right: 8px;
}
}
}
}
.canvas-panel {
}
.option-item {
display: flex;
align-items: center;
.is-duplicate {
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #f56c6c inset;
}
}
.duplicate-tip {
color: #f56c6c;
font-size: 12px;
margin-right: 10px;
}
}
.footer-actions {
width: 100%;
background: #fff;
padding: 10px 20px;
margin-top: 60px;
text-align: right;
border-top: 1px solid #dcdfe6;
}
}
</style>