feat: add new product survey step three

dev-QQq
chenyuan 3 weeks ago
parent 01e416726c
commit 8613cf70cc

@ -134,3 +134,39 @@ export function checkNewProductSurveyStepTwo(form) {
headers: { repeatSubmit: false },
});
}
function validateNewProductSurveyStepThreeDemo(payload) {
const expectedDimensions = ["核心层", "有形层", "延伸层"];
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (rows.length !== expectedDimensions.length) {
throw new Error("请完成核心层、有形层、延伸层分析");
}
const dimensions = rows.map((row) => String(row?.dimension || "").trim());
if (new Set(dimensions).size !== expectedDimensions.length || expectedDimensions.some((dimension) => !dimensions.includes(dimension))) {
throw new Error("分析维度不完整或重复");
}
const invalidRow = rows.find((row) => {
const points = Array.isArray(row?.points) ? row.points : [];
return points.map((point) => String(point || "").trim()).filter(Boolean).length < 2;
});
if (invalidRow) {
throw new Error(`${invalidRow.dimension || "该层"}至少填写 2 条分析要点`);
}
return { valid: true, message: "校验通过" };
}
export function checkNewProductSurveyStepThree(payload) {
if (isStudentDemo()) {
try {
return Promise.resolve({ code: 200, data: validateNewProductSurveyStepThreeDemo(payload) });
} catch (error) {
return Promise.reject(error);
}
}
return request({
url: "/api/student-training-answers/new-product-survey/step-3/validate",
method: "post",
data: payload,
headers: { repeatSubmit: false },
});
}

@ -154,10 +154,10 @@
<p class="section-eyebrow">Step 03</p>
<h3>{{ getStepName(3) }}</h3>
</div>
<span class="step-chip">产品1 / 产品2 / 产品3</span>
<span class="step-chip">三层次对照分析</span>
</div>
<p class="step-desc">
根据查找的产品从核心层有形层延伸层开展三层次分析对比不同产品如何创造用户价值
基于上一步采集的真实数据运用三层次理论进行深度分析参考示例填写下表每层至少填写 2 条要点并按换行分条
</p>
<div class="training-table-wrap">
@ -165,21 +165,22 @@
<thead>
<tr>
<th>分析维度</th>
<th>内容要求</th>
<th>实例AI学习机</th>
<th>产品1</th>
<th>产品2</th>
<th>产品3</th>
<th>{{ step2Form.productName || '所选产品' }}分析</th>
</tr>
</thead>
<tbody>
<tr v-for="row in step3Rows" :key="row.dimension">
<td class="dimension-cell">{{ row.dimension }}</td>
<td class="requirement-cell">{{ row.requirement }}</td>
<td class="example-cell">{{ row.example }}</td>
<td><textarea v-model="row.product1" class="cell-textarea" /></td>
<td><textarea v-model="row.product2" class="cell-textarea" /></td>
<td><textarea v-model="row.product3" class="cell-textarea" /></td>
<td class="dimension-cell"><strong>{{ row.dimension }}</strong><span>{{ row.definition }}</span></td>
<td class="example-cell">
<ol class="reference-points">
<li v-for="point in row.referencePoints" :key="point">{{ point }}</li>
</ol>
</td>
<td class="analysis-input-cell">
<textarea v-model="row.analysis" class="cell-textarea" rows="7" placeholder="每行填写一条分析要点" />
<p class="analysis-count" :class="{ 'is-ready': countStepThreePoints(row.analysis) >= 2 }">已填写 {{ countStepThreePoints(row.analysis) }} / 至少 2 </p>
</td>
</tr>
</tbody>
</table>
@ -269,7 +270,7 @@ import {
TrendCharts,
} from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkNewProductSurveyStepOne, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { checkNewProductSurveyStepOne, checkNewProductSurveyStepThree, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user";
import { extractTrainingStepNames } from "@/views/training/taskKeyMap";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
@ -357,33 +358,14 @@ const STEP_ONE_SELLING_POINTS = [
const createStep2Form = () => ({ platform: "", scene: "", customScene: "", keyword: "", productName: "", images: [] });
const createStep3Rows = () => [
{
dimension: "核心层",
requirement: "说明产品解决的核心痛点、用户需求和核心利益。",
example: "解决学生学习效率低、家长辅导时间不足等痛点,提供个性化学习支持。",
product1: "",
product2: "",
product3: "",
},
{
dimension: "有形层",
requirement: "记录功能、设计、品牌、参数、包装、价格等可见要素。",
example: "护眼屏、AI语音互动、拍照搜题、错题本、课程资源、学习报告。",
product1: "",
product2: "",
product3: "",
},
{
dimension: "延伸层",
requirement: "分析售后、会员、内容更新、物流、生态兼容和数据安全等附加服务。",
example: "课程持续更新、家长端管理、云端错题同步、售后保修、会员内容权益。",
product1: "",
product2: "",
product3: "",
},
const STEP_THREE_REFERENCE_ROWS = [
{ dimension: "核心层", definition: "AI技术解决的核心需求/功能", referencePoints: ["大模型作业批改", "自适应学习路径规划"] },
{ dimension: "有形层", definition: "外观/材质/参数/价格策略", referencePoints: ["护眼屏+铝合金机身", "搭载NPU芯片", "全平台统一定价2999元某网红直播间专属价2699元"] },
{ dimension: "延伸层", definition: "软件升级/专属服务/社群运营", referencePoints: ["终身免费题库更新", "VIP学科规划师1对1", "家长互助社群及资料共享微信群"] },
];
const createStep3Rows = () => STEP_THREE_REFERENCE_ROWS.map((row) => ({ ...row, analysis: "" }));
const createStep4Form = () => ({
coreValue: "",
tangibleValue: "",
@ -406,7 +388,7 @@ const currentStudyTip = computed(() => {
const tips = {
1: "当前步骤建议:先从案例或电商详情页中拆出核心层、有形层、延伸层,不要把功能和用户利益混在一起。",
2: "当前步骤建议:选择一个主流电商平台与真实 AI 产品,用“核心词 + 属性词 + 场景词”搜索并保留主图、详情页截图。",
3: "当前步骤建议:同一维度横向比较 3 个产品,重点找出 AI 技术在哪一层创造了差异化。",
3: "当前步骤建议:将上一步选定产品的卖点按核心层、有形层、延伸层拆分,每层至少记录两条可核对的分析要点。",
4: "当前步骤建议:把前面三步的发现收束为产品开发启发,说明哪些价值值得继续强化。",
};
return tips[currentStep.value];
@ -623,7 +605,19 @@ function applyStep3Answer(value) {
const parsed = safeParse(value);
const rows = Array.isArray(parsed) ? parsed : parsed?.step3Rows || parsed?.rows || parsed?.layerRows;
if (!Array.isArray(rows) || !rows.length) return false;
step3Rows.value = normalizeRows(rows, createStep3Rows(), "dimension");
const savedByDimension = rows.reduce((result, row) => {
if (row?.dimension) result[row.dimension] = row;
return result;
}, {});
step3Rows.value = createStep3Rows().map((row, index) => {
const saved = savedByDimension[row.dimension] || rows[index] || {};
const points = Array.isArray(saved.points) ? saved.points : [];
const legacyValue = [saved.analysis, saved.product1, saved.product2, saved.product3].find((item) => String(item || "").trim());
return {
...row,
analysis: points.length ? points.map((item) => String(item || "").trim()).filter(Boolean).join("\n") : String(legacyValue || ""),
};
});
return true;
}
@ -667,6 +661,26 @@ function normalizeRows(rows, fallback, matchKey = "") {
}));
}
function splitStepThreePoints(value) {
return String(value || "")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function countStepThreePoints(value) {
return splitStepThreePoints(value).length;
}
function buildStepThreeValidationPayload() {
return {
rows: step3Rows.value.map((row) => ({
dimension: row.dimension,
points: splitStepThreePoints(row.analysis),
})),
};
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE";
return {
@ -674,7 +688,7 @@ function buildAnswerPayload(status) {
currentStep: currentStep.value,
step1Answer: JSON.stringify({ classifications: classifications.value, checkResult: checkResult.value }),
step2Answer: JSON.stringify(step2Form.value),
step3Answer: JSON.stringify(step3Rows.value),
step3Answer: JSON.stringify(buildStepThreeValidationPayload()),
step4Answer: JSON.stringify(step4Form.value),
};
}
@ -714,6 +728,20 @@ async function nextStep() {
}
return;
}
if (currentStep.value === 3) {
checking.value = true;
try {
await checkNewProductSurveyStepThree(buildStepThreeValidationPayload());
await saveAnswer("IN_PROGRESS", true);
proxy?.$modal?.msgSuccess("第三步分析已校验并保存");
advanceStep();
} catch (error) {
proxy?.$modal?.msgWarning(error?.message || "请确保每个层次至少填写 2 条分析要点");
} finally {
checking.value = false;
}
return;
}
if (currentStep.value !== 1) {
advanceStep();
return;
@ -1513,9 +1541,26 @@ function exportOutcome() {
}
.dimension-cell {
min-width: 170px;
color: #eafaff;
text-align: left;
strong,
span {
display: block;
}
strong {
margin-bottom: 7px;
font-weight: 900;
text-align: center;
}
span {
color: #9fc6da;
font-size: 12px;
font-weight: 700;
line-height: 1.55;
}
}
.requirement-cell,
@ -1529,6 +1574,28 @@ function exportOutcome() {
color: #f5d99d;
}
.reference-points {
display: grid;
gap: 8px;
margin: 0;
padding-left: 20px;
}
.analysis-input-cell {
min-width: 280px;
}
.analysis-count {
margin: 8px 2px 0;
color: #ffb1b1;
font-size: 12px;
font-weight: 700;
&.is-ready {
color: #86e7bd;
}
}
.example-hint {
display: flex;
gap: 8px;

@ -24,6 +24,11 @@ assert.match(page, /@click="previewStepTwoImage\(image\.url\)"/, "uploaded produ
assert.match(page, /step2Answer: JSON\.stringify\(step2Form\.value\)/, "step two must persist the survey form instead of table rows");
assert.match(page, /checkNewProductSurveyStepTwo\(step2Form\.value\)/, "step two must request server-side validation before advancing");
assert.match(api, /new-product-survey\/step-2\/validate/, "the student API must call the step-two validation endpoint");
assert.match(page, /const STEP_THREE_REFERENCE_ROWS = \[/, "step three must define the fixed AI learning-machine reference rows");
assert.match(page, /step2Form\.productName \|\| '所选产品'/, "step three must use the surveyed product name as its analysis-column heading");
assert.match(page, /countStepThreePoints\(row\.analysis\)/, "step three must show the number of entered analysis points");
assert.match(page, /checkNewProductSurveyStepThree\(buildStepThreeValidationPayload\(\)\)/, "step three must validate its rows before advancing");
assert.match(api, /new-product-survey\/step-3\/validate/, "the student API must call the step-three validation endpoint");
assert.match(materialButton, /下载案例资料/, "the download button must use a fixed student-facing label");
assert.doesNotMatch(materialButton, /\{\{ material\?\.name \|\| "案例文档" \}\}/, "the upload file name must not become button text");

Loading…
Cancel
Save