From a4177c98d36d9929aa6b19aebf59dd45c559e5ca Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 11 Aug 2026 21:48:40 +0800 Subject: [PATCH] feat: add consumer behavior questionnaire editor --- src/api/studentTrainingAnswer.js | 36 ++ src/views/demand/consumer-behavior.vue | 456 +++++++++++++++++++++++-- 2 files changed, 472 insertions(+), 20 deletions(-) diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index 4cfd190..76520c8 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -371,3 +371,39 @@ export function checkConsumerBehaviorStepTwo(payload) { headers: { repeatSubmit: false }, }); } + +export function checkConsumerBehaviorStepThree(payload) { + if (isStudentDemo()) { + const importedDocumentUrl = String(payload?.importedDocumentUrl || "").trim(); + const questions = Array.isArray(payload?.questions) ? payload.questions : []; + const requiredSections = ["基本信息", "购买行为", "需求偏好", "开放建议"]; + const validQuestions = questions.filter((item) => String(item?.title || "").trim()); + const complete = requiredSections.every((section) => validQuestions.some((item) => item.section === section)) + && validQuestions.some((item) => item.section === "需求偏好" && item.type === "LIKERT"); + if (!importedDocumentUrl && !complete) { + return Promise.reject(new Error("请完整设计包含四个部分且含Likert五级量表的问卷,或导入Word问卷")); + } + return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } }); + } + return request({ + url: "/api/student-training-answers/consumer-behavior/step-3/validate", + method: "post", + data: payload, + headers: { repeatSubmit: false }, + }); +} + +export function previewConsumerBehaviorQuestionnaireWord(data) { + if (isStudentDemo()) { + return Promise.reject(new Error("演示模式不支持 Word 文档预览")); + } + return request({ + url: "/api/student-training-answers/consumer-behavior/step-3/word-preview", + method: "post", + data, + headers: { + "Content-Type": "multipart/form-data", + repeatSubmit: false, + }, + }); +} diff --git a/src/views/demand/consumer-behavior.vue b/src/views/demand/consumer-behavior.vue index 2872909..561ed80 100644 --- a/src/views/demand/consumer-behavior.vue +++ b/src/views/demand/consumer-behavior.vue @@ -49,6 +49,13 @@ :accept="uploadAccept" @change="handleFileChange" /> +
@@ -127,27 +134,63 @@
-

- 请根据上一步骤整理的Excel文件,利用Excel进行数据可视化,上传Excel可视化操作截图。 -

+
+

+ 结合前面的实训分析,设计一份完整的消费者需求调研问卷,包含基本信息、购买行为、需求偏好和开放建议四部分。需求偏好部分请用 Likert 五级量表评价第 2 步归类的功能。 +

+ + + 导入问卷(Word 文档) + +
+ +
+ + {{ form.questionnaireImport.name }} + +
-
-
{{ group.title }}
-
-
- {{ item.label }} - - 上传Excel图形截图 - - {{ form.chartFiles[item.key].name }} -
+
+
+ Questionnaire Studio +

在线编写消费者需求调研问卷

+

通过分区、题型与选项设置,快速完成可执行的问卷结构。

+ 进入问卷编辑器
-
- 4.结合开放性问题建议以及上述数据可视化结果,进行用户购买智能产品决策因素分析,给出分析报告,注意结合不同类别调研数据进行综合性交叉分析。 -
- +

+ 已编辑 {{ form.questionnaireQuestions.length }} 题;{{ form.questionnaireImport?.name ? "已导入 1 份 Word 问卷,可随时预览。" : "也可导入已完成的 .docx 问卷。" }} +

+ + +
+

请覆盖基本信息、购买行为、需求偏好和开放建议四部分;需求偏好至少设置一题 Likert 五级量表。

+ 新增题目 +
+
+
+ {{ String(index + 1).padStart(2, "0") }} +
+ + + + + + + + +

量表固定为:非常不重要、较不重要、一般、较重要、非常重要

+
+ +
+
+
+ + +
{{ form.questionnaireImport.previewContent }}
+

暂未解析到可预览内容。

+
@@ -201,10 +244,11 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; -import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; +import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, checkConsumerBehaviorStepThree, getStudentTrainingAnswer, previewConsumerBehaviorQuestionnaireWord, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; const TASK_KEY = "consumer-behavior"; const KANO_NEED_LIMIT = 20; +let questionnaireQuestionSequence = 0; const { proxy } = getCurrentInstance(); const defaultRequirement = "消费者需求与行为分析用于识别目标用户的购买动机、决策因素、渠道偏好和使用场景,为智能产品功能设计与市场切入提供依据。"; @@ -389,9 +433,13 @@ const ReportFields = defineComponent({ const taskConfig = ref(null); const saving = ref(false); const uploading = ref(false); +const questionnaireImporting = ref(false); const fileInputRef = ref(null); +const questionnaireImportInputRef = ref(null); const questionnaireEditorRef = ref(null); const uploadContext = ref({ type: "", key: "" }); +const questionnaireEditorVisible = ref(false); +const showQuestionnairePreview = ref(false); const uploadAccept = computed(() => { if (uploadContext.value.type === "questionnaire") return ".pdf"; @@ -405,6 +453,8 @@ function createForm() { activeStep: 0, targetHypotheses: createTargetHypotheses(), kanoNeeds: createKanoNeeds(), + questionnaireQuestions: createQuestionnaireQuestions(), + questionnaireImport: null, questionnaireText: questionnaireTemplate, questionnaireFileName: "", questionnaireFileUrl: "", @@ -428,6 +478,18 @@ function createEmptyTargetHypothesis() { } const kanoTypeOptions = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"]; +const questionnaireSections = [ + { value: "基本信息", label: "基本信息" }, + { value: "购买行为", label: "购买行为" }, + { value: "需求偏好", label: "需求偏好" }, + { value: "开放建议", label: "开放建议" }, +]; +const questionnaireQuestionTypes = [ + { value: "SINGLE", label: "单选题" }, + { value: "MULTIPLE", label: "多选题" }, + { value: "LIKERT", label: "Likert 五级量表" }, + { value: "TEXT", label: "开放题" }, +]; function createKanoNeeds() { return Array.from({ length: 3 }, createEmptyKanoNeed); @@ -437,6 +499,20 @@ function createEmptyKanoNeed() { return { feature: "", kanoType: "", featureDirection: "" }; } +function createQuestionnaireQuestions() { + return [ + createQuestionnaireQuestion("基本信息", "SINGLE"), + createQuestionnaireQuestion("购买行为", "SINGLE"), + createQuestionnaireQuestion("需求偏好", "LIKERT"), + createQuestionnaireQuestion("开放建议", "TEXT"), + ]; +} + +function createQuestionnaireQuestion(section = "基本信息", type = "SINGLE") { + questionnaireQuestionSequence += 1; + return { id: `question-${Date.now()}-${questionnaireQuestionSequence}`, section, type, title: "", options: "" }; +} + const form = ref(createForm()); const taskTitle = computed(() => taskConfig.value?.taskName || "消费者需求与行为"); @@ -447,7 +523,7 @@ const flowSteps = computed(() => parseArray(taskConfig.value?.steps, []).slice(0 const activeStepName = computed(() => flowSteps.value[form.value.activeStep] || ""); const progressItems = computed(() => [ - { text: form.value.questionnaireFileName ? "已完成:问卷上传" : "待完成:问卷设计与上传", status: form.value.questionnaireFileName ? "done" : "" }, + { text: hasQuestionnaireDesign() ? "已完成:问卷设计" : "待完成:问卷设计", status: hasQuestionnaireDesign() ? "done" : "" }, { text: form.value.dataExcelFileName ? "已完成:数据整理上传" : "待完成:整理并上传Excel", status: form.value.dataExcelFileName ? "done" : "" }, { text: Object.keys(form.value.chartFiles).length ? "进行中:数据可视化截图" : "待完成:数据可视化截图", status: Object.keys(form.value.chartFiles).length ? "done" : "" }, { text: hasReportContent() ? "进行中:分析报告" : "待完成:分析报告", status: hasReportContent() ? "done" : "" }, @@ -507,6 +583,8 @@ function applySavedAnswer(value) { questionnaireText: normalizeQuestionnaireContent(parsed.questionnaireText || questionnaireTemplate), targetHypotheses: normalizeTargetHypotheses(parsed.targetHypotheses), kanoNeeds: normalizeKanoNeeds(parsed.kanoNeeds), + questionnaireQuestions: normalizeQuestionnaireQuestions(parsed.questionnaireQuestions), + questionnaireImport: normalizeQuestionnaireImport(parsed.questionnaireImport), chartFiles: parsed.chartFiles || {}, report: normalizeReport(parsed.report), }; @@ -534,6 +612,26 @@ function normalizeKanoNeeds(rows) { })); } +function normalizeQuestionnaireQuestions(rows) { + if (!Array.isArray(rows) || !rows.length) return createQuestionnaireQuestions(); + return rows.map((row, index) => ({ + id: String(row?.id || `question-restored-${index}`), + section: questionnaireSections.some((item) => item.value === row?.section) ? row.section : "基本信息", + type: questionnaireQuestionTypes.some((item) => item.value === row?.type) ? row.type : "SINGLE", + title: String(row?.title || ""), + options: String(row?.options || ""), + })); +} + +function normalizeQuestionnaireImport(value) { + if (!value || typeof value !== "object" || !value.name) return null; + return { + name: String(value.name), + url: String(value.url || ""), + previewContent: String(value.previewContent || ""), + }; +} + function normalizeReport(value = {}) { return Object.keys(emptyReport).reduce((report, key) => { const currentValue = value?.[key] || ""; @@ -617,6 +715,29 @@ function removeKanoNeed() { form.value.kanoNeeds.pop(); } +function questionPlaceholder(section) { + const placeholders = { + 基本信息: "例如:您的年龄处于以下哪个区间?", + 购买行为: "例如:您通常通过什么渠道购买智能助眠产品?", + 需求偏好: "例如:白噪声可定时关闭对您有多重要?", + 开放建议: "例如:您希望智能助眠产品增加哪些功能?", + }; + return placeholders[section] || "请输入题目内容"; +} + +function addQuestionnaireQuestion() { + form.value.questionnaireQuestions.push(createQuestionnaireQuestion()); +} + +function removeQuestionnaireQuestion(index) { + if (form.value.questionnaireQuestions.length <= 4) return; + form.value.questionnaireQuestions.splice(index, 1); +} + +function triggerQuestionnaireImport() { + questionnaireImportInputRef.value?.click(); +} + function viewTargetUserProfile() { window.open("/demand/people", "_blank", "noopener"); } @@ -650,12 +771,40 @@ async function handleFileChange(event) { } } +async function handleQuestionnaireImport(event) { + const file = event.target.files?.[0]; + if (!file) return; + if (!/\.docx$/i.test(file.name)) { + proxy?.$modal?.msgWarning("请导入 .docx 格式的 Word 问卷,以便在线预览"); + event.target.value = ""; + return; + } + const data = new FormData(); + data.append("file", file); + questionnaireImporting.value = true; + try { + const previewRes = await previewConsumerBehaviorQuestionnaireWord(data); + const uploadRes = await uploadStudentTrainingFile(data); + const url = uploadRes?.url || uploadRes?.fileName || uploadRes?.data?.url || uploadRes?.data?.fileName || ""; + form.value.questionnaireImport = { + name: file.name, + url, + previewContent: String(previewRes?.data?.content || ""), + }; + showQuestionnairePreview.value = true; + proxy?.$modal?.msgSuccess("问卷导入成功"); + } finally { + questionnaireImporting.value = false; + event.target.value = ""; + } +} + function goPrev() { if (form.value.activeStep > 0) setActiveStep(form.value.activeStep - 1); } async function goNext() { - if (form.value.activeStep <= 1) await persist("IN_PROGRESS"); + if (form.value.activeStep <= 2) await persist("IN_PROGRESS"); if (form.value.activeStep < flowSteps.value.length - 1) setActiveStep(form.value.activeStep + 1); } @@ -663,6 +812,11 @@ function hasReportContent() { return Object.values(form.value.report || {}).some((value) => String(value || "").trim()); } +function hasQuestionnaireDesign() { + return Boolean(form.value.questionnaireImport?.name) + || form.value.questionnaireQuestions.some((item) => String(item?.title || "").trim()); +} + function buildAnswerPayload(status) { syncQuestionnaireText(); const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"; @@ -689,6 +843,12 @@ async function persist(status = "IN_PROGRESS") { if (status !== "RESET" && (form.value.activeStep === 1 || status === "SUBMITTED")) { await checkConsumerBehaviorStepTwo({ needs: form.value.kanoNeeds }); } + if (status !== "RESET" && (form.value.activeStep === 2 || status === "SUBMITTED")) { + await checkConsumerBehaviorStepThree({ + questions: form.value.questionnaireQuestions, + importedDocumentUrl: form.value.questionnaireImport?.url || "", + }); + } await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功"); } finally { @@ -724,6 +884,10 @@ function exportOutcome() { "KANO 用户需求分类:", ...form.value.kanoNeeds.map((item, index) => `${index + 1}. 功能:${item.feature || ""}\nKANO类型:${item.kanoType || ""}\n功能方向:${item.featureDirection || ""}`), "", + "消费者需求调研问卷:", + form.value.questionnaireImport?.name ? `导入问卷:${form.value.questionnaireImport.name}` : "", + ...form.value.questionnaireQuestions.map((item, index) => `${index + 1}. [${item.section}] ${item.title || ""}(${questionnaireQuestionTypes.find((type) => type.value === item.type)?.label || ""})`), + "", "问卷设计:", questionnaireTextForExport(), "", @@ -1324,6 +1488,229 @@ function exportOutcome() { color: #8da5b2; } +.questionnaire-design-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px 28px; + margin-bottom: 20px; +} + +.questionnaire-design-desc { + max-width: 820px; + margin: 0; + color: #d9eaf4; + font-size: 16px; + font-weight: 600; +} + +.questionnaire-import-action, +.questionnaire-editor-launcher__action { + min-width: 178px; + height: 44px; + border: 1px solid rgba(92, 224, 255, 0.7); + border-radius: 10px; + color: #ffffff; + background: linear-gradient(95deg, #0b84bd, #096491); + font-size: 15px; + font-weight: 800; +} + +.questionnaire-imported-file { + display: inline-flex; + align-items: center; + gap: 10px; + max-width: 100%; + min-height: 42px; + padding: 0 13px; + margin-bottom: 18px; + border: 1px solid rgba(87, 215, 255, 0.4); + border-radius: 12px; + color: #dff9ff; + background: rgba(10, 97, 137, 0.28); + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + button { + flex: 0 0 auto; + border: 0; + color: #75e5ff; + background: transparent; + cursor: pointer; + font-weight: 800; + } +} + +.questionnaire-editor-launcher { + display: flex; + align-items: center; + justify-content: space-between; + gap: 32px; + min-height: 190px; + padding: 32px 40px; + overflow: hidden; + border: 1px solid rgba(54, 195, 243, 0.36); + border-radius: 20px; + background: + radial-gradient(circle at 86% 18%, rgba(31, 207, 255, 0.22), transparent 34%), + linear-gradient(115deg, rgba(7, 59, 87, 0.94), rgba(2, 20, 34, 0.9)); +} + +.questionnaire-editor-launcher__copy { + position: relative; + z-index: 1; + + h4 { + margin: 7px 0; + color: #ffffff; + font-size: 23px; + } + + p { + max-width: 600px; + margin: 0; + color: #b6d1df; + line-height: 1.7; + } +} + +.questionnaire-editor-launcher__action { + flex: 0 0 auto; + min-width: 170px; + height: 52px; +} + +.questionnaire-editor-summary { + margin: 13px 0 0; + color: #72deff; + font-size: 13px; +} + +:deep(.questionnaire-editor-dialog .el-dialog), +:deep(.questionnaire-preview-dialog .el-dialog) { + overflow: hidden; + border: 1px solid rgba(70, 200, 247, 0.35); + border-radius: 18px; + color: #e7f8ff; + background: #061724; +} + +:deep(.questionnaire-editor-dialog .el-dialog__title), +:deep(.questionnaire-preview-dialog .el-dialog__title) { + color: #ffffff; + font-weight: 800; +} + +.questionnaire-editor-dialog__intro { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; + + p { + margin: 0; + color: #b9d3e0; + line-height: 1.7; + } +} + +.questionnaire-editor-add { + flex: 0 0 auto; + border-color: rgba(92, 224, 255, 0.7); + color: #ffffff; + background: linear-gradient(95deg, #0b84bd, #096491); +} + +.questionnaire-question-list { + display: grid; + gap: 12px; + max-height: min(58vh, 700px); + padding-right: 5px; + overflow: auto; +} + +.questionnaire-question-card { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 14px; + border: 1px solid rgba(67, 144, 184, 0.34); + border-radius: 14px; + background: rgba(13, 45, 63, 0.66); +} + +.questionnaire-question-card__number { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid rgba(80, 222, 255, 0.45); + border-radius: 50%; + color: #74e5ff; + font-size: 12px; + font-weight: 800; +} + +.questionnaire-question-card__fields { + display: grid; + grid-template-columns: 180px 180px minmax(0, 1fr); + gap: 10px; + + :deep(.el-select__wrapper), + :deep(.el-input__wrapper) { + min-height: 38px; + border: 1px solid rgba(67, 134, 166, 0.8); + box-shadow: none; + color: #eaf7ff; + background: rgba(5, 26, 39, 0.9); + } + + :deep(.el-input__inner) { color: #eaf7ff; } +} + +.likert-tip { + grid-column: 1 / -1; + margin: 0; + color: #7ce0f8; + font-size: 12px; +} + +.questionnaire-question-card__remove { + padding: 8px 10px; + border: 1px solid rgba(104, 161, 187, 0.5); + border-radius: 8px; + color: #bddfeb; + background: transparent; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } +} + +.questionnaire-word-preview { + max-height: min(62vh, 760px); + padding: 20px; + overflow: auto; + border: 1px solid rgba(77, 167, 220, 0.28); + border-radius: 12px; + color: #e2f3fa; + background: rgba(3, 25, 38, 0.86); + white-space: pre-wrap; + line-height: 1.85; +} + +.questionnaire-preview-empty { + color: #b9d3e0; +} + .questionnaire-desc { flex: 1; margin: 0; @@ -1739,6 +2126,35 @@ function exportOutcome() { flex-direction: column; } + .questionnaire-design-head, + .questionnaire-editor-launcher, + .questionnaire-editor-dialog__intro { + align-items: stretch; + flex-direction: column; + } + + .questionnaire-import-action, + .questionnaire-editor-launcher__action { + width: 100%; + } + + .questionnaire-editor-launcher { + padding: 26px; + } + + .questionnaire-question-card { + grid-template-columns: 32px minmax(0, 1fr); + } + + .questionnaire-question-card__fields { + grid-template-columns: 1fr; + } + + .questionnaire-question-card__remove { + grid-column: 2; + justify-self: end; + } + .step-indicator { align-items: stretch; flex-direction: column;