帮我实现一个可拖拽式的调查问卷demo,左侧是可拖拽生成的题型类别(比如:单选题、多选题、填空题),中间是可拖拽生成题型的区域,区域内的题型可以自行上下拖拽,右侧是点击中间区域对应的题型,可以对其进行编辑修改,设置禁用项,输入长度限制等等。总结来说类似于"调问"和"问卷星"。

简简单单,你就跟产品或者项目经理似的给 Cursor 提任务需求就可以了,他就能够快速自动帮你实现,话不多说,先看下成果~

Cursor 在整个实现过程前前后后可能也就不到 10分钟 就完事了,想当初为了实现此功能,先是搜索了大量的Git项目案例,找到合适的项目demo后,又是开始熟悉了解使用方法。
一开始没有找项目案例,纯找实现方法,发现实现起来还是有点费劲
后来找的项目demo,里面使用了 Vue 的一款 拖拽插件 vue.draggable
推荐学习文章:《记录Vue.Draggable拖拽组件的使用历程》
《记录Vue.Draggable拖拽组件的使用历程》
https://blog.csdn.net/weixin_58099903/article/details/125655355?fromshare=blogdetail&sharetype=blogdetail&sharerId=125655355&sharerefer=PC&sharesource=weixin_58099903&sharefrom=from_link前前后后包括搜索项目案例和学习使用方法就花费了 一周 的时间,可见其成本和效率,
可以毫不夸张的说,使用 Cursor 开发,效率提升至少 10 倍!
接下来咱们细看一下 Cursor 是如何一步一步实现的吧~
1、先查看项目结构与依赖。
2、自动 安装 vue-draggable-plus 并注册 Pinia。
3、创建类型、工厂函数、Pinia store 与问卷搭建页面。

1、src\types\survey.ts --- 题型与数据结构
TypeScriptexport type QuestionKind = 'single' | 'multiple' | 'fill' export interface OptionItem { id: string label: string disabled?: boolean } export interface SurveyQuestionBase { id: string type: QuestionKind title: string required: boolean } export interface SingleQuestion extends SurveyQuestionBase { type: 'single' options: OptionItem[] } export interface MultipleQuestion extends SurveyQuestionBase { type: 'multiple' options: OptionItem[] minSelect?: number maxSelect?: number } export interface FillQuestion extends SurveyQuestionBase { type: 'fill' placeholder?: string minLength?: number maxLength?: number } export type SurveyQuestion = SingleQuestion | MultipleQuestion | FillQuestion export interface PaletteItem { id: string kind: QuestionKind label: string description: string }2、src\utils\surveyFactory.ts ---
createQuestion、PALETTE_ITEMS
TypeScriptimport type { FillQuestion, MultipleQuestion, OptionItem, QuestionKind, SingleQuestion, SurveyQuestion, } from '@/types/survey' function uid(): string { return crypto.randomUUID() } function defaultOptions(): OptionItem[] { return ['选项 A', '选项 B', '选项 C', '选项 D'].map((label) => ({ id: uid(), label, disabled: false, })) } export function createQuestion(kind: QuestionKind): SurveyQuestion { const base = { id: uid(), title: '未命名题目', required: false } if (kind === 'single') { const q: SingleQuestion = { ...base, type: 'single', options: defaultOptions(), } return q } if (kind === 'multiple') { const q: MultipleQuestion = { ...base, type: 'multiple', options: defaultOptions(), minSelect: 1, maxSelect: undefined, } return q } const q: FillQuestion = { ...base, type: 'fill', placeholder: '请输入内容', minLength: undefined, maxLength: 200, } return q } export const PALETTE_ITEMS = [ { id: 'palette-single', kind: 'single' as const, label: '单选题', description: '从多个选项中选一项', }, { id: 'palette-multiple', kind: 'multiple' as const, label: '多选题', description: '从多个选项中选多项', }, { id: 'palette-fill', kind: 'fill' as const, label: '填空题', description: '短文本输入', }, ]3、src\stores\surveyBuilder.ts --- Pinia:题目列表、选中项、
patchQuestion、updateOption等
TypeScriptimport { defineStore } from 'pinia' import { computed, ref } from 'vue' import type { OptionItem, SurveyQuestion } from '@/types/survey' export const useSurveyBuilderStore = defineStore('surveyBuilder', () => { const questions = ref<SurveyQuestion[]>([]) const selectedId = ref<string | null>(null) const selectedQuestion = computed(() => { if (!selectedId.value) return null return questions.value.find((q) => q.id === selectedId.value) ?? null }) function selectQuestion(id: string | null) { selectedId.value = id } function removeQuestion(id: string) { questions.value = questions.value.filter((q) => q.id !== id) if (selectedId.value === id) selectedId.value = null } function patchQuestion(id: string, patch: Partial<SurveyQuestion>) { const i = questions.value.findIndex((q) => q.id === id) if (i === -1) return const cur = questions.value[i] if (!cur) return questions.value[i] = { ...cur, ...patch } as SurveyQuestion } function updateOption( questionId: string, optionId: string, patch: Partial<OptionItem>, ) { const q = questions.value.find((x) => x.id === questionId) if (!q || (q.type !== 'single' && q.type !== 'multiple')) return const opts = q.options.map((o) => o.id === optionId ? { ...o, ...patch } : o, ) patchQuestion(questionId, { options: opts } as Partial<SurveyQuestion>) } function addOption(questionId: string) { const q = questions.value.find((x) => x.id === questionId) if (!q || (q.type !== 'single' && q.type !== 'multiple')) return const next: OptionItem = { id: crypto.randomUUID(), label: `选项 ${q.options.length + 1}`, disabled: false, } patchQuestion(questionId, { options: [...q.options, next], } as Partial<SurveyQuestion>) } function removeOption(questionId: string, optionId: string) { const q = questions.value.find((x) => x.id === questionId) if (!q || (q.type !== 'single' && q.type !== 'multiple')) return if (q.options.length <= 1) return patchQuestion(questionId, { options: q.options.filter((o) => o.id !== optionId), } as Partial<SurveyQuestion>) } return { questions, selectedId, selectedQuestion, selectQuestion, removeQuestion, patchQuestion, updateOption, addOption, removeOption, } })4、src\components\survey-builder\QuestionInspector.vue --- 右侧表单(Element Plus)
TypeScript<template> <div class="inspector"> <h3 class="inspector-title">题目设置</h3> <div v-if="!q" class="inspector-empty"> 请在中间区域点击一道题目,即可在此编辑标题、选项与校验规则。 </div> <el-form v-else label-position="top" class="inspector-form"> <el-form-item label="题目标题"> <el-input :model-value="q.title" maxlength="200" show-word-limit placeholder="请输入标题" @update:model-value="store.patchQuestion(q.id, { title: $event })" /> </el-form-item> <el-form-item label="必答"> <el-switch :model-value="q.required" @update:model-value="store.patchQuestion(q.id, { required: $event })" /> </el-form-item> <template v-if="q.type === 'single' || q.type === 'multiple'"> <el-divider content-position="left">选项</el-divider> <p class="hint">可关闭某选项前的「启用」以在答题时隐藏该选项(禁用项)。</p> <div v-for="(opt, idx) in q.options" :key="opt.id" class="option-row" > <span class="opt-index">{{ idx + 1 }}.</span> <el-input class="opt-input" :model-value="opt.label" placeholder="选项文案" @update:model-value=" store.updateOption(q.id, opt.id, { label: $event }) " /> <el-switch :model-value="!opt.disabled" inline-prompt active-text="启用" inactive-text="禁用" style="flex-shrink: 0" @update:model-value=" store.updateOption(q.id, opt.id, { disabled: !$event }) " /> <el-button type="danger" link :disabled="q.options.length <= 1" @click="store.removeOption(q.id, opt.id)" > 删除 </el-button> </div> <el-button class="add-opt" @click="store.addOption(q.id)"> + 添加选项 </el-button> </template> <template v-if="q.type === 'multiple'"> <el-divider content-position="left">多选限制</el-divider> <el-form-item label="最少选几项"> <el-input-number :model-value="q.minSelect ?? 0" :min="0" :max="q.options.length" controls-position="right" @update:model-value="patchMultipleMin($event)" /> </el-form-item> <el-form-item label="最多选几项(空为不限制)"> <el-input-number :model-value="q.maxSelect ?? undefined" :min="1" :max="q.options.length" controls-position="right" :value-on-clear="undefined" @update:model-value="patchMultipleMax($event)" /> </el-form-item> </template> <template v-if="q.type === 'fill'"> <el-divider content-position="left">填空设置</el-divider> <el-form-item label="占位提示"> <el-input :model-value="q.placeholder ?? ''" placeholder="输入框占位文字" @update:model-value="patchFillPlaceholder($event)" /> </el-form-item> <el-form-item label="最少字数"> <el-input-number :model-value="q.minLength ?? undefined" :min="0" :max="99999" controls-position="right" :value-on-clear="undefined" placeholder="不限制" @update:model-value="patchFillMinLen($event)" /> </el-form-item> <el-form-item label="最多字数"> <el-input-number :model-value="q.maxLength ?? undefined" :min="1" :max="99999" controls-position="right" :value-on-clear="undefined" placeholder="不限制" @update:model-value="patchFillMaxLen($event)" /> </el-form-item> </template> </el-form> </div> </template> <script setup lang="ts"> import { computed } from 'vue' import { useSurveyBuilderStore } from '@/stores/surveyBuilder' const store = useSurveyBuilderStore() const q = computed(() => store.selectedQuestion) function patchMultipleMin(val: number | undefined) { const cur = q.value if (!cur || cur.type !== 'multiple') return store.patchQuestion(cur.id, { minSelect: val ?? undefined }) } function patchMultipleMax(val: number | undefined) { const cur = q.value if (!cur || cur.type !== 'multiple') return store.patchQuestion(cur.id, { maxSelect: val ?? undefined }) } function patchFillPlaceholder(val: string) { const cur = q.value if (!cur || cur.type !== 'fill') return store.patchQuestion(cur.id, { placeholder: val || undefined }) } function patchFillMinLen(val: number | undefined) { const cur = q.value if (!cur || cur.type !== 'fill') return store.patchQuestion(cur.id, { minLength: val ?? undefined }) } function patchFillMaxLen(val: number | undefined) { const cur = q.value if (!cur || cur.type !== 'fill') return store.patchQuestion(cur.id, { maxLength: val ?? undefined }) } </script> <style scoped> .inspector { height: 100%; display: flex; flex-direction: column; min-height: 0; } .inspector-title { margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #303133; } .inspector-empty { font-size: 13px; line-height: 1.6; color: #909399; padding: 8px 0; } .inspector-form { flex: 1; overflow: auto; padding-right: 4px; } .hint { margin: 0 0 12px; font-size: 12px; color: #909399; line-height: 1.5; } .option-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .opt-index { width: 22px; flex-shrink: 0; font-size: 13px; color: #606266; } .opt-input { flex: 1; min-width: 0; } .add-opt { width: 100%; margin-top: 4px; } </style>值得一提的是,Cursor 会自己检测代码并修正优化。
5、src\views\SurveyBuilderDemo.vue --- 三栏布局 + 双
VueDraggable
TypeScript<template> <div class="survey-builder-wrap"> <header class="survey-header"> <h1 class="survey-title">问卷搭建器 Demo</h1> <p class="survey-sub"> 从左侧拖入题型到中间;中间可拖拽排序;点击题目在右侧编辑。 </p> <router-link class="survey-link" to="/marquee">返回滚动演示</router-link> </header> <div class="survey-grid"> <aside class="panel panel-left"> <h2 class="panel-heading">题型库</h2> <p class="panel-tip">按住拖入中间画布</p> <VueDraggable v-model="paletteItems" :group="{ name: 'survey', pull: 'clone', put: false }" :clone="clonePaletteItem" :sort="false" :animation="200" class="palette-draggable" > <div v-for="p in paletteItems" :key="p.id" class="palette-card" > <span class="palette-icon" aria-hidden="true">≡</span> <div class="palette-body"> <div class="palette-label">{{ p.label }}</div> <div class="palette-desc">{{ p.description }}</div> </div> </div> </VueDraggable> </aside> <main class="panel panel-center"> <h2 class="panel-heading">问卷内容</h2> <VueDraggable v-model="questions" :group="{ name: 'survey', pull: true, put: true }" :animation="200" handle=".q-drag-handle" ghost-class="ghost-card" class="canvas-draggable" > <div v-for="(q, index) in questions" :key="q.id" class="question-card" :class="{ 'is-active': selectedId === q.id }" @click="store.selectQuestion(q.id)" > <div class="q-drag-handle" title="拖动排序" @click.stop> ⋮⋮ </div> <div class="q-main"> <div class="q-head"> <span class="q-no">Q{{ index + 1 }}</span> <el-tag size="small" type="info">{{ typeLabel(q.type) }}</el-tag> <span v-if="q.required" class="q-req">必答</span> <el-button type="danger" link class="q-del" @click.stop="store.removeQuestion(q.id)" > 删除 </el-button> </div> <div class="q-title">{{ q.title }}</div> <div class="q-preview"> <template v-if="q.type === 'single'"> <el-radio-group class="preview-group" :model-value="''"> <el-radio v-for="opt in q.options" :key="opt.id" :label="opt.id" :disabled="opt.disabled" > {{ opt.label }} </el-radio> </el-radio-group> </template> <template v-else-if="q.type === 'multiple'"> <el-checkbox-group class="preview-group" :model-value="[]"> <el-checkbox v-for="opt in q.options" :key="opt.id" :label="opt.id" :disabled="opt.disabled" > {{ opt.label }} </el-checkbox> </el-checkbox-group> </template> <template v-else> <el-input :model-value="''" :placeholder="q.placeholder || '答题者输入'" disabled /> </template> </div> </div> </div> </VueDraggable> <div v-if="questions.length === 0" class="canvas-empty"> 将左侧「单选题 / 多选题 / 填空题」拖放到此处开始组卷 </div> </main> <aside class="panel panel-right"> <QuestionInspector /> </aside> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue' import { storeToRefs } from 'pinia' import { VueDraggable } from 'vue-draggable-plus' import { useSurveyBuilderStore } from '@/stores/surveyBuilder' import { PALETTE_ITEMS, createQuestion } from '@/utils/surveyFactory' import type { PaletteItem, SurveyQuestion } from '@/types/survey' import QuestionInspector from '@/components/survey-builder/QuestionInspector.vue' const store = useSurveyBuilderStore() const { questions, selectedId } = storeToRefs(store) const paletteItems = ref<PaletteItem[]>([...PALETTE_ITEMS]) function clonePaletteItem(item: PaletteItem): SurveyQuestion { return createQuestion(item.kind) } function typeLabel(type: SurveyQuestion['type']) { const map = { single: '单选题', multiple: '多选题', fill: '填空题' } return map[type] } </script> <style scoped> .survey-builder-wrap { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; min-height: calc(100vh - 32px); box-sizing: border-box; padding: 16px 20px 24px; background: linear-gradient(180deg, #f5f7fa 0%, #eef1f6 100%); } .survey-header { max-width: 1200px; margin: 0 auto 16px; } .survey-title { margin: 0 0 6px; font-size: 22px; font-weight: 700; color: #303133; } .survey-sub { margin: 0 0 8px; font-size: 14px; color: #606266; } .survey-link { font-size: 13px; color: #409eff; } .survey-grid { display: grid; grid-template-columns: 240px minmax(360px, 1fr) 300px; gap: 16px; max-width: 1200px; margin: 0 auto; align-items: stretch; min-height: 520px; } .panel { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); padding: 14px 12px; display: flex; flex-direction: column; min-height: 0; } .panel-heading { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: #303133; } .panel-tip { margin: 0 0 12px; font-size: 12px; color: #909399; } .palette-draggable { display: flex; flex-direction: column; gap: 10px; } .palette-card { display: flex; align-items: flex-start; gap: 10px; padding: 10px 10px; border: 1px dashed #c0c4cc; border-radius: 8px; cursor: grab; background: #fafafa; transition: border-color 0.2s, background 0.2s; } .palette-card:hover { border-color: #409eff; background: #ecf5ff; } .palette-icon { color: #909399; font-size: 18px; line-height: 1.2; user-select: none; } .palette-label { font-size: 14px; font-weight: 600; color: #303133; } .palette-desc { margin-top: 4px; font-size: 12px; color: #909399; line-height: 1.4; } .panel-center { position: relative; } .canvas-draggable { flex: 1; min-height: 200px; display: flex; flex-direction: column; gap: 12px; padding-bottom: 8px; } .canvas-empty { pointer-events: none; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -30%); max-width: 280px; text-align: center; font-size: 14px; color: #c0c4cc; line-height: 1.6; } .question-card { display: flex; gap: 8px; padding: 12px; border: 2px solid transparent; border-radius: 8px; background: #fafafa; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; } .question-card:hover { background: #f0f9ff; } .question-card.is-active { border-color: #409eff; background: #ecf5ff; box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15); } .q-drag-handle { flex-shrink: 0; width: 28px; text-align: center; color: #c0c4cc; cursor: grab; user-select: none; font-size: 14px; line-height: 1.8; } .q-drag-handle:active { cursor: grabbing; } .q-main { flex: 1; min-width: 0; } .q-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; } .q-no { font-weight: 600; color: #606266; font-size: 13px; } .q-req { font-size: 12px; color: #f56c6c; } .q-del { margin-left: auto; } .q-title { font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 10px; } .q-preview { font-size: 13px; } .preview-group { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; } .panel-right { overflow: hidden; } :deep(.ghost-card) { opacity: 0.55; background: #e1f0ff !important; } </style>6、src\main.ts --- 已注册 Pinia、Element Plus(中文语言包)
TypeScriptimport './assets/main.css' import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' createApp(App) .use(createPinia()) .use(ElementPlus, { locale: zhCn }) .use(router) .mount('#app')7、src\router\index.ts --- 路由
/survey-builder,/默认重定向到/survey-builder
TypeScriptimport { createRouter, createWebHistory } from 'vue-router' import ScrollMarqueeDemo from '@/views/ScrollMarqueeDemo.vue' import SurveyBuilderDemo from '@/views/SurveyBuilderDemo.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', redirect: '/survey-builder', }, { path: '/marquee', name: 'marquee', component: ScrollMarqueeDemo, }, { path: '/survey-builder', name: 'survey-builder', component: SurveyBuilderDemo, }, ], }) export default router大功告成:
已实现一个可拖拽的问卷搭建 Demo,说明如下。
功能概览
| 区域 | 作用 |
|---|---|
| 左侧「题型库」 | 单选题 / 多选题 / 填空题三种卡片,拖到中间即可生成题目(vue-draggable-plus 的 group + pull: 'clone')。 |
| 中间「问卷内容」 | 展示已添加题目,左侧 ⋮⋮ 为拖动手柄,仅在手柄上拖动可上下排序;点击卡片选中并在右侧编辑;「删除」仅移除该题。 |
| 右侧「题目设置」 | 对当前选中题编辑:标题、必答;单选/多选:选项文案、启用/禁用选项、增删选项;多选额外:最少/最多选几项;填空:占位符、最少/最多字数。 |
涉及文件
src/types/survey.ts--- 题型与数据结构src/utils/surveyFactory.ts---createQuestion、PALETTE_ITEMSsrc/stores/surveyBuilder.ts--- Pinia:题目列表、选中项、patchQuestion、updateOption等src/views/SurveyBuilderDemo.vue--- 三栏布局 + 双VueDraggablesrc/components/survey-builder/QuestionInspector.vue--- 右侧表单(Element Plus)src/main.ts--- 已注册 Pinia、Element Plus(中文语言包)src/router/index.ts--- 路由/survey-builder,/默认重定向到/survey-builder
本地执行 npm run dev,打开 http://localhost:5173/survey-builder(或根路径 /)即可。
最终看一眼效果图:



