feat: add new product survey classification step

dev-QQq
chenyuan 3 weeks ago
parent 7e7ad3c1b1
commit eb798fdc52

@ -1,6 +1,20 @@
import request from "@/utils/request"; import request from "@/utils/request";
import { getDemoAnswerKey, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo"; import { getDemoAnswerKey, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const newProductSurveyStepOneAnswers = {
"selling-point-1": "核心层",
"selling-point-2": "有形层",
"selling-point-3": "核心层",
"selling-point-4": "核心层",
"selling-point-5": "有形层",
"selling-point-6": "有形层",
"selling-point-7": "有形层",
"selling-point-8": "延伸层",
"selling-point-9": "延伸层",
"selling-point-10": "延伸层",
"selling-point-11": "有形层",
};
function demoResult(taskKey) { function demoResult(taskKey) {
try { return JSON.parse(sessionStorage.getItem(getDemoAnswerKey(taskKey)) || "null"); } catch (error) { return null; } try { return JSON.parse(sessionStorage.getItem(getDemoAnswerKey(taskKey)) || "null"); } catch (error) { return null; }
} }
@ -49,3 +63,34 @@ export function uploadStudentTrainingFile(data) {
}, },
}); });
} }
export function checkNewProductSurveyStepOne(items) {
if (isStudentDemo()) {
const results = Array.isArray(items)
? items.map((item) => {
const correct = newProductSurveyStepOneAnswers[item?.id] === item?.category;
return {
id: item?.id,
correct,
message: correct ? "分类正确" : "分类有误,请重新选择",
};
})
: [];
const correctCount = results.filter((item) => item.correct).length;
return Promise.resolve({
code: 200,
data: {
items: results,
correctCount,
totalCount: results.length,
allCorrect: results.length === Object.keys(newProductSurveyStepOneAnswers).length && correctCount === results.length,
},
});
}
return request({
url: "/api/student-training-answers/new-product-survey/step-1/check",
method: "post",
data: { items },
headers: { repeatSubmit: false },
});
}

@ -50,60 +50,38 @@
<p class="section-eyebrow">Step 01</p> <p class="section-eyebrow">Step 01</p>
<h3>{{ getStepName(1) }}</h3> <h3>{{ getStepName(1) }}</h3>
</div> </div>
<span class="step-chip">案例信息提取</span> <span class="step-chip">卖点分类练习</span>
</div> </div>
<p class="step-desc">请根据实训要求中的案例一信息分别从核心层有形层延伸层提取信息并填写对应信息</p> <p class="step-desc">查看案例产品详情仔细阅读主图及详情页信息对下列产品卖点进行三层次分类</p>
<label class="field-block"> <div class="training-table-wrap classification-table-wrap">
<span>该款智能产品平台类目是</span> <table class="training-data-table classification-table">
<input v-model="step1Form.category" class="input-field input-single" /> <thead>
</label> <tr>
<th>序号</th>
<label class="field-block"> <th>卖点</th>
<span>核心层从上图可以提炼出该款智能产品解决的用户痛点为</span> <th>三层次分类</th>
<textarea v-model="step1Form.corePain" class="input-field" rows="4" /> </tr>
</label> </thead>
<tbody>
<label class="field-block"> <tr v-for="(point, index) in STEP_ONE_SELLING_POINTS" :key="point.id" :class="feedbackClass(point.id)">
<span>有形层从产品主图可以看出关于产品有形层的主要卖点为</span> <td class="classification-number">{{ index + 1 }}</td>
<textarea v-model="step1Form.tangibleSellingPoint" class="input-field" rows="4" /> <td class="classification-selling-point">{{ point.text }}</td>
</label> <td class="classification-select-cell">
<el-select v-model="classifications[point.id]" placeholder="请选择分类" :disabled="checking" @change="clearFeedback(point.id)">
<label class="field-block"> <el-option v-for="category in LAYER_OPTIONS" :key="category" :label="category" :value="category" />
<span>价格带为</span> </el-select>
<div class="price-options"> <p v-if="checkResult?.itemsById?.[point.id]" class="classification-feedback">
<button {{ checkResult.itemsById[point.id].message }}
v-for="option in priceOptions" </p>
:key="option.value" </td>
type="button" </tr>
:class="['price-option', { active: step1Form.priceBand === option.value }]" </tbody>
@click="step1Form.priceBand = option.value" </table>
>
{{ step1Form.priceBand === option.value ? "√" : "" }}{{ option.label }}
</button>
</div>
</label>
<p class="step-desc">延伸层从产品主图可以看出关于产品品牌保证发货等延伸层相关描述为</p>
<div class="form-grid three">
<label class="field-block">
<span>店铺名称</span>
<input v-model="step1Form.storeName" class="input-field input-single" />
</label>
<label class="field-block">
<span>店铺星级</span>
<input v-model="step1Form.storeRating" class="input-field input-single" />
</label>
<label class="field-block">
<span>发货优势</span>
<input v-model="step1Form.shippingAdvantage" class="input-field input-single" />
</label>
</div> </div>
<p v-if="checkResult" class="classification-summary" :class="{ 'is-success': checkResult.allCorrect }">
<label class="field-block"> 本次判题{{ checkResult.correctCount }} / {{ checkResult.totalCount }} 项分类正确
<span>根据该产品主图详情评价等信息在产品介绍外观设计和功能上您有何改进建议</span> </p>
<textarea v-model="step1Form.improvement" class="input-field" rows="4" />
</label>
</div> </div>
</section> </section>
@ -216,12 +194,12 @@
<el-icon><ArrowLeft /></el-icon> <el-icon><ArrowLeft /></el-icon>
上一步 上一步
</button> </button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress"> <button type="button" class="btn-nav btn-save" :disabled="saving || checking" @click="saveCurrentProgress">
<el-icon><CircleCheck /></el-icon> <el-icon><CircleCheck /></el-icon>
保存当前进度 保存当前进度
</button> </button>
<button type="button" class="btn-nav btn-primary" :disabled="currentStep === steps.length" @click="nextStep"> <button type="button" class="btn-nav btn-primary" :disabled="currentStep === steps.length || saving || checking" @click="nextStep">
下一步 {{ checking ? "正在判题" : "下一步" }}
<el-icon><ArrowRight /></el-icon> <el-icon><ArrowRight /></el-icon>
</button> </button>
</div> </div>
@ -263,7 +241,7 @@ import {
TrendCharts, TrendCharts,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { checkNewProductSurveyStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import { extractTrainingStepNames } from "@/views/training/taskKeyMap"; import { extractTrainingStepNames } from "@/views/training/taskKeyMap";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
@ -276,14 +254,15 @@ const userStore = useUserStore();
const taskConfig = ref(null); const taskConfig = ref(null);
const saving = ref(false); const saving = ref(false);
const checking = ref(false);
const draftReady = ref(false); const draftReady = ref(false);
const currentStep = ref(1); const currentStep = ref(1);
const defaultSteps = [ const defaultSteps = [
{ no: 1, name: "产品三层次理论应用", icon: Search }, { no: 1, name: "产品卖点的三层次分类", icon: Search },
{ no: 2, name: "选定调查平台与产品", icon: DataAnalysis }, { no: 2, name: "选定调查平台与产品", icon: DataAnalysis },
{ no: 3, name: "AI产品三层次分析", icon: TrendCharts }, { no: 3, name: "AI产品三层次分析", icon: TrendCharts },
{ no: 4, name: "AI技术价值分析", icon: Operation }, { no: 4, name: "AI应用价值分析", icon: Operation },
]; ];
const defaultGoals = [ const defaultGoals = [
@ -313,23 +292,22 @@ const steps = computed(() => {
})); }));
}); });
const priceOptions = [ const LAYER_OPTIONS = ["核心层", "有形层", "延伸层"];
{ label: "1000元以下的基础款", value: "basic" },
{ label: "1000-3000元的进阶全能款", value: "advanced" }, const STEP_ONE_SELLING_POINTS = [
{ label: "3000元以上的旗舰高端款", value: "flagship" }, { id: "selling-point-1", text: "生日礼物女孩子毕业儿童3到6岁10小女童" },
{ id: "selling-point-2", text: "AI智能机器人毛绒玩具" },
{ id: "selling-point-3", text: "豆包+DeepSeek六大混合模型多语种AI对话" },
{ id: "selling-point-4", text: "覆盖百科、算术、语文、诗词、AI翻译等早教能力强还具备情绪感知安抚功能" },
{ id: "selling-point-5", text: "安全亲肤,零甲醛零荧光剂,母婴级不掉毛" },
{ id: "selling-point-6", text: "柔软手感舒心无忧内赠USB充电线" },
{ id: "selling-point-7", text: "嘴巴会动,翅膀煽动,脖子扭动,触摸感应触发,说话跳舞同步" },
{ id: "selling-point-8", text: "可自由定制角色/专属音色,打造宝宝专属小伙伴,适配哄睡、早教、陪伴多场景" },
{ id: "selling-point-9", text: "快递免运费预计7小时内发货" },
{ id: "selling-point-10", text: "大促价保、假一赔四、极速退款、7天无理由退换" },
{ id: "selling-point-11", text: "店铺优惠后169元起" },
]; ];
const createStep1Form = () => ({
category: "",
corePain: "",
tangibleSellingPoint: "",
priceBand: "basic",
storeName: "",
storeRating: "",
shippingAdvantage: "",
improvement: "",
});
const createStep2Rows = () => [ const createStep2Rows = () => [
{ scene: "", platform: "", keyword: "", category: "", sales: "" }, { scene: "", platform: "", keyword: "", category: "", sales: "" },
{ scene: "", platform: "", keyword: "", category: "", sales: "" }, { scene: "", platform: "", keyword: "", category: "", sales: "" },
@ -369,7 +347,8 @@ const createStep4Form = () => ({
extendedValue: "", extendedValue: "",
}); });
const step1Form = ref(createStep1Form()); const classifications = ref({});
const checkResult = ref(null);
const step2Rows = ref(createStep2Rows()); const step2Rows = ref(createStep2Rows());
const step3Rows = ref(createStep3Rows()); const step3Rows = ref(createStep3Rows());
const step4Form = ref(createStep4Form()); const step4Form = ref(createStep4Form());
@ -405,7 +384,7 @@ onMounted(async () => {
}); });
watch( watch(
[step1Form, step2Rows, step3Rows, step4Form, currentStep], [classifications, checkResult, step2Rows, step3Rows, step4Form, currentStep],
() => persistDraft(), () => persistDraft(),
{ deep: true } { deep: true }
); );
@ -495,12 +474,51 @@ function applySavedAnswer(value) {
function applyStep1Answer(value) { function applyStep1Answer(value) {
const parsed = safeParse(value); const parsed = safeParse(value);
if (!parsed || typeof parsed !== "object") return false; if (!parsed || typeof parsed !== "object") return false;
const source = parsed.step1Form || parsed; const source = parsed.classifications || parsed.step1Form || parsed;
const fields = Object.keys(createStep1Form()); const restored = STEP_ONE_SELLING_POINTS.reduce((result, point) => {
const hasValue = fields.some((key) => source[key] !== undefined && source[key] !== null && source[key] !== ""); if (LAYER_OPTIONS.includes(source?.[point.id])) {
if (!hasValue) return false; result[point.id] = source[point.id];
step1Form.value = { ...createStep1Form(), ...pickFields(source, fields) }; }
return true; return result;
}, {});
classifications.value = restored;
checkResult.value = normalizeCheckResult(parsed.checkResult);
return Object.keys(restored).length > 0;
}
function normalizeCheckResult(value) {
if (!value || typeof value !== "object" || !Array.isArray(value.items)) return null;
const itemsById = value.items.reduce((result, item) => {
if (item?.id) result[item.id] = item;
return result;
}, {});
return {
items: value.items,
itemsById,
correctCount: Number(value.correctCount || 0),
totalCount: Number(value.totalCount || value.items.length),
allCorrect: Boolean(value.allCorrect),
};
}
function feedbackClass(id) {
const item = checkResult.value?.itemsById?.[id];
if (!item) return "";
return item.correct ? "classification-row--correct" : "classification-row--incorrect";
}
function clearFeedback(id) {
if (checkResult.value?.itemsById?.[id]) {
checkResult.value = null;
}
}
function hasCompletedClassifications() {
return STEP_ONE_SELLING_POINTS.every((point) => LAYER_OPTIONS.includes(classifications.value[point.id]));
}
function buildCheckItems() {
return STEP_ONE_SELLING_POINTS.map((point) => ({ id: point.id, category: classifications.value[point.id] }));
} }
function applyStep2Answer(value) { function applyStep2Answer(value) {
@ -564,7 +582,7 @@ function buildAnswerPayload(status) {
return { return {
saveAction, saveAction,
currentStep: currentStep.value, currentStep: currentStep.value,
step1Answer: JSON.stringify(step1Form.value), step1Answer: JSON.stringify({ classifications: classifications.value, checkResult: checkResult.value }),
step2Answer: JSON.stringify(step2Rows.value), step2Answer: JSON.stringify(step2Rows.value),
step3Answer: JSON.stringify(step3Rows.value), step3Answer: JSON.stringify(step3Rows.value),
step4Answer: JSON.stringify(step4Form.value), step4Answer: JSON.stringify(step4Form.value),
@ -591,7 +609,34 @@ function submitTask() {
return saveAnswer("SUBMITTED"); return saveAnswer("SUBMITTED");
} }
function nextStep() { async function nextStep() {
if (currentStep.value !== 1) {
advanceStep();
return;
}
if (!hasCompletedClassifications()) {
proxy?.$modal?.msgWarning("请完成全部卖点分类后再进入下一步");
return;
}
checking.value = true;
try {
const res = await checkNewProductSurveyStepOne(buildCheckItems());
checkResult.value = normalizeCheckResult(res?.data);
await saveAnswer("IN_PROGRESS", true);
if (checkResult.value?.allCorrect) {
proxy?.$modal?.msgSuccess("分类全部正确,已进入下一步");
advanceStep();
} else {
proxy?.$modal?.msgWarning("存在分类错误,请根据提示修改后重新判题");
}
} catch (error) {
proxy?.$modal?.msgError(error?.message || "判题失败,请稍后重试");
} finally {
checking.value = false;
}
}
function advanceStep() {
if (currentStep.value < steps.value.length) { if (currentStep.value < steps.value.length) {
currentStep.value += 1; currentStep.value += 1;
} }
@ -604,7 +649,8 @@ function prevStep() {
} }
function resetExample() { function resetExample() {
step1Form.value = createStep1Form(); classifications.value = {};
checkResult.value = null;
step2Rows.value = createStep2Rows(); step2Rows.value = createStep2Rows();
step3Rows.value = createStep3Rows(); step3Rows.value = createStep3Rows();
step4Form.value = createStep4Form(); step4Form.value = createStep4Form();
@ -619,7 +665,8 @@ function buildOutcome() {
taskId: taskConfig.value?.id || "", taskId: taskConfig.value?.id || "",
currentStep: currentStep.value, currentStep: currentStep.value,
steps: steps.value.map((item) => item.name), steps: steps.value.map((item) => item.name),
step1Form: step1Form.value, classifications: classifications.value,
checkResult: checkResult.value,
step2Rows: step2Rows.value, step2Rows: step2Rows.value,
step3Rows: step3Rows.value, step3Rows: step3Rows.value,
step4Form: step4Form.value, step4Form: step4Form.value,
@ -1249,6 +1296,61 @@ function exportOutcome() {
background: rgba(13, 64, 88, 0.62); background: rgba(13, 64, 88, 0.62);
} }
.classification-table-wrap {
margin-top: 20px;
}
.classification-table th:first-child,
.classification-table td:first-child {
width: 84px;
text-align: center;
}
.classification-table th:last-child,
.classification-table td:last-child {
width: 240px;
}
.classification-number {
color: #9bdcff;
font-weight: 800;
}
.classification-selling-point {
line-height: 1.65;
}
.classification-select-cell :deep(.el-select) {
width: 100%;
}
.classification-row--correct td {
background: rgba(23, 181, 121, 0.12);
}
.classification-row--incorrect td {
background: rgba(239, 91, 91, 0.13);
}
.classification-feedback,
.classification-summary {
margin: 8px 0 0;
color: #ffb1b1;
font-size: 12px;
line-height: 1.45;
}
.classification-row--correct .classification-feedback,
.classification-summary.is-success {
color: #86e7bd;
}
.classification-summary {
margin-top: 16px;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.new-product-value-page { .new-product-value-page {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -1312,6 +1414,10 @@ function exportOutcome() {
.price-options { .price-options {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.classification-table {
min-width: 720px;
}
} }
</style> </style>

@ -0,0 +1,19 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const page = fs.readFileSync(path.join(root, "src/views/foundation/new-product-survey.vue"), "utf8");
const api = fs.readFileSync(path.join(root, "src/api/studentTrainingAnswer.js"), "utf8");
assert.match(page, /const STEP_ONE_SELLING_POINTS = \[/, "step one must define its fixed selling-point questions");
assert.ok((page.match(/selling-point-/g) || []).length >= 11, "step one must contain all 11 selling points");
assert.match(page, /核心层/, "classification options must include 核心层");
assert.match(page, /有形层/, "classification options must include 有形层");
assert.match(page, /延伸层/, "classification options must include 延伸层");
assert.match(page, /checkNewProductSurveyStepOne/, "step one must request server-side grading");
assert.match(page, /checkResult/, "step one must retain grading feedback");
assert.match(page, /TrainingMaterialButton/, "the existing material download button must remain in place");
assert.match(api, /new-product-survey\/step-1\/check/, "the student API must call the step-one grading endpoint");
console.log("new product survey step-one contract passed");
Loading…
Cancel
Save