feat: add consumer behavior kano needs

dev-QQq
chenyuan 3 weeks ago
parent 0c63009c88
commit 10147d5f5c

@ -352,3 +352,22 @@ export function checkConsumerBehaviorStepOne(payload) {
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }
export function checkConsumerBehaviorStepTwo(payload) {
const needs = Array.isArray(payload?.needs) ? payload.needs : [];
const allowedTypes = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"];
if (isStudentDemo()) {
const incomplete = needs.find((item) => !String(item?.feature || "").trim()
|| !allowedTypes.includes(item?.kanoType) || !String(item?.featureDirection || "").trim());
if (!needs.length || needs.length > 20 || incomplete) {
return Promise.reject(new Error("请完整填写功能、KANO类型和对应功能方向最多20行"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
return request({
url: "/api/student-training-answers/consumer-behavior/step-2/validate",
method: "post",
data: { needs },
headers: { repeatSubmit: false },
});
}

@ -90,21 +90,40 @@
</section> </section>
<section v-else-if="form.activeStep === 1" class="step-panel"> <section v-else-if="form.activeStep === 1" class="step-panel">
<p class="task-desc">假如本次调研共回收有效问卷 500 以下是各题目的数据统计结果</p> <div class="kano-head">
<div class="data-board"> <p class="task-desc kano-desc">
<pre>{{ surveyStats }}</pre> 在正式设计问卷之前需要理解消费者需求的层次结构不同类型的需求对购买决策的影响方式截然不同结合案例资料通过 KANO 模型进行用户需求分类填写下表
</div> </p>
<p class="task-desc">请把数据整理到 Excel 并上传 Excel 文件</p> <div class="kano-actions">
<div class="upload-row"> <el-button class="hypothesis-action" :disabled="form.kanoNeeds.length >= KANO_NEED_LIMIT" @click="addKanoNeed"></el-button>
<el-button class="upload-action" :loading="uploading" @click="triggerFileSelect('dataExcel')"> <el-button class="hypothesis-action hypothesis-action--quiet" :disabled="form.kanoNeeds.length <= 1" @click="removeKanoNeed"></el-button>
<el-icon><Upload /></el-icon>
上传Excel文件
</el-button>
<div v-if="form.dataExcelFileName" class="file-chip">
<el-icon><Document /></el-icon>
<span>{{ form.dataExcelFileName }}</span>
</div> </div>
</div> </div>
<div class="kano-table-wrap">
<table class="kano-table">
<thead>
<tr>
<th>编号</th>
<th>功能</th>
<th>对应KANO类型</th>
<th>对应功能方向</th>
</tr>
</thead>
<tbody>
<tr v-for="(need, index) in form.kanoNeeds" :key="index">
<td class="kano-index">{{ index + 1 }}</td>
<td><textarea v-model="need.feature" class="hypothesis-textarea kano-textarea" placeholder="示例:灯光亮度可无级调节" aria-label="" /></td>
<td class="kano-select-cell">
<el-select v-model="need.kanoType" class="kano-select" placeholder="请选择类型" aria-label="对应KANO类型">
<el-option v-for="option in kanoTypeOptions" :key="option" :label="option" :value="option" />
</el-select>
</td>
<td><textarea v-model="need.featureDirection" class="hypothesis-textarea kano-textarea" placeholder="示例:照明系统" aria-label="" /></td>
</tr>
</tbody>
</table>
</div>
<p class="hypothesis-tip">功能与功能方向均可填写KANO 类型请从下拉菜单选择最多可填写 {{ KANO_NEED_LIMIT }} </p>
</section> </section>
<section v-else-if="form.activeStep === 2" class="step-panel"> <section v-else-if="form.activeStep === 2" class="step-panel">
@ -182,9 +201,10 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkConsumerBehaviorStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-behavior"; const TASK_KEY = "consumer-behavior";
const KANO_NEED_LIMIT = 20;
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const defaultRequirement = "消费者需求与行为分析用于识别目标用户的购买动机、决策因素、渠道偏好和使用场景,为智能产品功能设计与市场切入提供依据。"; const defaultRequirement = "消费者需求与行为分析用于识别目标用户的购买动机、决策因素、渠道偏好和使用场景,为智能产品功能设计与市场切入提供依据。";
@ -384,6 +404,7 @@ function createForm() {
return { return {
activeStep: 0, activeStep: 0,
targetHypotheses: createTargetHypotheses(), targetHypotheses: createTargetHypotheses(),
kanoNeeds: createKanoNeeds(),
questionnaireText: questionnaireTemplate, questionnaireText: questionnaireTemplate,
questionnaireFileName: "", questionnaireFileName: "",
questionnaireFileUrl: "", questionnaireFileUrl: "",
@ -406,6 +427,16 @@ function createEmptyTargetHypothesis() {
return { targetGroup: "", keyFinding: "", coreHypothesis: "" }; return { targetGroup: "", keyFinding: "", coreHypothesis: "" };
} }
const kanoTypeOptions = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"];
function createKanoNeeds() {
return Array.from({ length: 3 }, createEmptyKanoNeed);
}
function createEmptyKanoNeed() {
return { feature: "", kanoType: "", featureDirection: "" };
}
const form = ref(createForm()); const form = ref(createForm());
const taskTitle = computed(() => taskConfig.value?.taskName || "消费者需求与行为"); const taskTitle = computed(() => taskConfig.value?.taskName || "消费者需求与行为");
@ -475,6 +506,7 @@ function applySavedAnswer(value) {
activeStep: Number.isInteger(parsed.activeStep) ? parsed.activeStep : 0, activeStep: Number.isInteger(parsed.activeStep) ? parsed.activeStep : 0,
questionnaireText: normalizeQuestionnaireContent(parsed.questionnaireText || questionnaireTemplate), questionnaireText: normalizeQuestionnaireContent(parsed.questionnaireText || questionnaireTemplate),
targetHypotheses: normalizeTargetHypotheses(parsed.targetHypotheses), targetHypotheses: normalizeTargetHypotheses(parsed.targetHypotheses),
kanoNeeds: normalizeKanoNeeds(parsed.kanoNeeds),
chartFiles: parsed.chartFiles || {}, chartFiles: parsed.chartFiles || {},
report: normalizeReport(parsed.report), report: normalizeReport(parsed.report),
}; };
@ -493,6 +525,15 @@ function normalizeTargetHypotheses(rows) {
})); }));
} }
function normalizeKanoNeeds(rows) {
if (!Array.isArray(rows) || !rows.length) return createKanoNeeds();
return rows.slice(0, KANO_NEED_LIMIT).map((row) => ({
feature: String(row?.feature || ""),
kanoType: kanoTypeOptions.includes(row?.kanoType) ? row.kanoType : "",
featureDirection: String(row?.featureDirection || ""),
}));
}
function normalizeReport(value = {}) { function normalizeReport(value = {}) {
return Object.keys(emptyReport).reduce((report, key) => { return Object.keys(emptyReport).reduce((report, key) => {
const currentValue = value?.[key] || ""; const currentValue = value?.[key] || "";
@ -563,6 +604,19 @@ function removeTargetHypothesis() {
form.value.targetHypotheses.pop(); form.value.targetHypotheses.pop();
} }
function addKanoNeed() {
if (form.value.kanoNeeds.length >= KANO_NEED_LIMIT) {
proxy?.$modal?.msgWarning(`最多可填写 ${KANO_NEED_LIMIT}`);
return;
}
form.value.kanoNeeds.push(createEmptyKanoNeed());
}
function removeKanoNeed() {
if (form.value.kanoNeeds.length <= 1) return;
form.value.kanoNeeds.pop();
}
function viewTargetUserProfile() { function viewTargetUserProfile() {
window.open("/demand/people", "_blank", "noopener"); window.open("/demand/people", "_blank", "noopener");
} }
@ -601,7 +655,7 @@ function goPrev() {
} }
async function goNext() { async function goNext() {
if (form.value.activeStep === 0) await persist("IN_PROGRESS"); if (form.value.activeStep <= 1) await persist("IN_PROGRESS");
if (form.value.activeStep < flowSteps.value.length - 1) setActiveStep(form.value.activeStep + 1); if (form.value.activeStep < flowSteps.value.length - 1) setActiveStep(form.value.activeStep + 1);
} }
@ -632,6 +686,9 @@ async function persist(status = "IN_PROGRESS") {
if (status !== "RESET" && (form.value.activeStep === 0 || status === "SUBMITTED")) { if (status !== "RESET" && (form.value.activeStep === 0 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepOne({ hypotheses: form.value.targetHypotheses }); await checkConsumerBehaviorStepOne({ hypotheses: form.value.targetHypotheses });
} }
if (status !== "RESET" && (form.value.activeStep === 1 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepTwo({ needs: form.value.kanoNeeds });
}
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功"); proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
} finally { } finally {
@ -664,6 +721,9 @@ function exportOutcome() {
"问卷调查目标人群与核心假设:", "问卷调查目标人群与核心假设:",
...form.value.targetHypotheses.map((item, index) => `${index + 1}. 目标人群:${item.targetGroup || ""}\n实训8关键发现${item.keyFinding || ""}\n核心假设${item.coreHypothesis || ""}`), ...form.value.targetHypotheses.map((item, index) => `${index + 1}. 目标人群:${item.targetGroup || ""}\n实训8关键发现${item.keyFinding || ""}\n核心假设${item.coreHypothesis || ""}`),
"", "",
"KANO 用户需求分类:",
...form.value.kanoNeeds.map((item, index) => `${index + 1}. 功能:${item.feature || ""}\nKANO类型${item.kanoType || ""}\n功能方向${item.featureDirection || ""}`),
"",
"问卷设计:", "问卷设计:",
questionnaireTextForExport(), questionnaireTextForExport(),
"", "",
@ -1070,7 +1130,8 @@ function exportOutcome() {
font-weight: 600; font-weight: 600;
} }
.hypothesis-actions { .hypothesis-actions,
.kano-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
@ -1078,6 +1139,7 @@ function exportOutcome() {
} }
.hypothesis-actions :deep(.hypothesis-action), .hypothesis-actions :deep(.hypothesis-action),
.kano-actions :deep(.hypothesis-action),
.training-eight-action { .training-eight-action {
min-width: 146px; min-width: 146px;
height: 42px; height: 42px;
@ -1089,7 +1151,8 @@ function exportOutcome() {
font-weight: 800; font-weight: 800;
} }
.hypothesis-actions :deep(.hypothesis-action--quiet) { .hypothesis-actions :deep(.hypothesis-action--quiet),
.kano-actions :deep(.hypothesis-action--quiet) {
border-color: rgba(115, 191, 220, 0.58); border-color: rgba(115, 191, 220, 0.58);
color: #d9f4ff; color: #d9f4ff;
background: rgba(13, 85, 117, 0.72); background: rgba(13, 85, 117, 0.72);
@ -1176,6 +1239,91 @@ function exportOutcome() {
font-size: 13px; font-size: 13px;
} }
.kano-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px 28px;
margin-bottom: 20px;
}
.kano-desc {
max-width: 780px;
margin: 0;
color: #d9eaf4;
font-size: 16px;
font-weight: 600;
}
.kano-table-wrap {
overflow-x: auto;
border: 1px solid rgba(0, 180, 255, 0.24);
border-radius: 18px;
background: rgba(1, 14, 26, 0.72);
}
.kano-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding: 10px 12px;
border-right: 1px solid rgba(77, 167, 220, 0.26);
border-bottom: 1px solid rgba(77, 167, 220, 0.26);
vertical-align: middle;
}
th {
color: #ecfbff;
background: linear-gradient(100deg, #158fc4, #08739f);
font-size: 16px;
font-weight: 800;
}
th:nth-child(1) { width: 9%; }
th:nth-child(2) { width: 42%; }
th:nth-child(3) { width: 25%; }
th:nth-child(4) { width: 24%; }
tr:last-child td { border-bottom: 0; }
th:last-child,
td:last-child { border-right: 0; }
}
.kano-index {
color: #83e9ff;
font-weight: 800;
text-align: center;
}
.kano-textarea {
min-height: 58px;
}
.kano-select-cell {
text-align: center;
}
.kano-select {
width: min(100%, 190px);
}
.kano-select :deep(.el-select__wrapper) {
min-height: 42px;
border: 1px solid rgba(67, 134, 166, 0.8);
border-radius: 10px;
box-shadow: none;
color: #eaf7ff;
background: rgba(7, 29, 43, 0.92);
}
.kano-select :deep(.el-select__placeholder) {
color: #8da5b2;
}
.questionnaire-desc { .questionnaire-desc {
flex: 1; flex: 1;
margin: 0; margin: 0;
@ -1576,14 +1724,21 @@ function exportOutcome() {
width: 100%; width: 100%;
} }
.hypothesis-actions { .hypothesis-actions,
.kano-actions {
justify-content: stretch; justify-content: stretch;
} }
.hypothesis-actions :deep(.hypothesis-action) { .hypothesis-actions :deep(.hypothesis-action),
.kano-actions :deep(.hypothesis-action) {
flex: 1; flex: 1;
} }
.kano-head {
align-items: stretch;
flex-direction: column;
}
.step-indicator { .step-indicator {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;

Loading…
Cancel
Save