From 5a45c631016a798b253d72a3dee198b0c5e20e19 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com>
Date: Wed, 26 Aug 2026 14:18:13 +0800
Subject: [PATCH] feat: refine market opportunity step two
---
.../product/market-opportunity-selection.vue | 17 +++---
.../market-opportunity-step2.static.test.cjs | 59 +++++++++++++++++++
2 files changed, 68 insertions(+), 8 deletions(-)
create mode 100644 tests/market-opportunity-step2.static.test.cjs
diff --git a/src/views/product/market-opportunity-selection.vue b/src/views/product/market-opportunity-selection.vue
index 7b78065..fae3638 100644
--- a/src/views/product/market-opportunity-selection.vue
+++ b/src/views/product/market-opportunity-selection.vue
@@ -18,7 +18,7 @@
第 1 步分类结果已自动带入。请填写四类一级指标权重和各维度内二级指标权重,系统将自动计算对应总权重。
请先完成并保存第 1 步的指标分类,再填写本步骤。
一级指标权重合计{{ primaryWeightTotal }}%对应总权重合计{{ correspondingWeightTotal }}%一级权重合计、每类维度内权重合计、对应总权重均须为 100%
-
+
@@ -79,8 +79,8 @@ const TASK_KEY = "market-opportunity-selection";
const DEFAULT_STEPS = ["指标分类", "指标权重设置", "综合得分计算", "数据可视化", "用户需求洞察", "选品结论"];
const categories = ["市场规模", "市场潜力", "竞争强度", "运营难度"];
const indicators = [
- { id: "search-count", name: "搜索次数(万次)", group: "市场规模", suggestion: "25–35" }, { id: "demand-supply-ratio", name: "需供比", group: "竞争强度", suggestion: "25–35" }, { id: "transaction-amount", name: "成交金额(万元)", group: "市场规模", suggestion: "65–75" },
- { id: "transaction-growth", name: "成交金额增速(%)", group: "市场潜力", suggestion: "55–65" }, { id: "category-transaction-growth", name: "类目成交增速(%)", group: "市场潜力", suggestion: "35–45" }, { id: "online-products", name: "在线商品数", group: "竞争强度", suggestion: "35–45" },
+ { id: "search-count", name: "搜索次数(万次)", group: "市场规模", suggestion: "25–35" }, { id: "transaction-amount", name: "成交金额(万元)", group: "市场规模", suggestion: "65–75" },
+ { id: "transaction-growth", name: "成交金额增速(%)", group: "市场潜力", suggestion: "55–65" }, { id: "category-transaction-growth", name: "类目成交增速(%)", group: "市场潜力", suggestion: "35–45" }, { id: "demand-supply-ratio", name: "需供比", group: "竞争强度", suggestion: "25–35" }, { id: "online-products", name: "在线商品数", group: "竞争强度", suggestion: "35–45" },
{ id: "online-merchants", name: "在线商家数", group: "竞争强度", suggestion: "25–35" }, { id: "organic-traffic-ratio", name: "自然流占比(%)", group: "运营难度", suggestion: "55–65" }, { id: "return-rate", name: "退货率(%)", group: "运营难度", suggestion: "35–45" },
];
const groupSuggestions = { "市场规模": "25–35", "市场潜力": "25–35", "竞争强度": "20–30", "运营难度": "10–20" };
@@ -90,7 +90,7 @@ const { proxy } = getCurrentInstance();
const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const visualizationInput = ref(null); const visualization = ref({ url: "", name: "" }); const stepOnePassed = ref(false); const stepTwoPassed = ref(false); const stepThreePassed = ref(false); const stepFourPassed = ref(false); const stepFivePassed = ref(false); const answers = ref(createAnswers()); const primaryWeights = ref(createPrimaryWeights()); const weights = ref(createWeights()); const scores = ref(createScores()); const reviewInsights = ref(createReviewInsights()); const developmentConclusion = ref(""); const dynamicStepAnswers = ref({}); const stepOneCheckResults = ref({});
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => { const configured = parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS); return DEFAULT_STEPS.map((fallback, index) => configured[index] || fallback); });
const progressItems = computed(() => indicators.map((item) => ({ text: `${answers.value[item.id] ? "已分类" : "待分类"}:${item.name}`, status: answers.value[item.id] ? "done" : "" })));
-const indicatorGroups = computed(() => categories.map((name) => ({ name, suggestion: groupSuggestions[name], items: indicators.filter((item) => answers.value[item.id] === name) })));
+const indicatorGroups = computed(() => categories.map((name) => ({ name, suggestion: groupSuggestions[name], items: indicators.filter((item) => item.group === name) })));
const primaryWeightTotal = computed(() => categories.reduce((total, group) => total + (Number(primaryWeights.value[group]) || 0), 0));
const correspondingWeightTotal = computed(() => roundWeight(indicators.reduce((total, item) => total + correspondingWeight(item), 0)));
onMounted(async () => { await loadTaskConfig(); await loadSavedAnswer(); });
@@ -103,7 +103,7 @@ function isValidRate(value) { return Number.isFinite(Number(value)) && Number(va
function hasCompleteReviewInsights() { return products.every((product) => { const insight = reviewInsights.value[product.id] || {}; return isValidRate(insight.favorableRate) && String(insight.painPoint || "").trim() && String(insight.unmetNeed || "").trim(); }); }
function parseArray(value, fallback) { if (Array.isArray(value)) return value.length ? value : fallback; if (!value) return fallback; try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback; } catch (error) { const list = String(value).split(/[;;|]/).map((item) => item.trim()).filter(Boolean); return list.length ? list : fallback; } }
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } }
-async function loadSavedAnswer() { try { const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data; if (answer?.step1Answer) { const parsed = JSON.parse(answer.step1Answer); answers.value = { ...createAnswers(), ...(parsed?.answers || parsed || {}) }; stepOnePassed.value = indicators.every((item) => Boolean(answers.value[item.id])); } if (answer?.step2Answer) { const parsed = JSON.parse(answer.step2Answer); primaryWeights.value = { ...createPrimaryWeights(), ...(parsed?.primaryWeights || {}) }; weights.value = { ...createWeights(), ...(parsed?.dimensionWeights || parsed?.weights || {}) }; stepTwoPassed.value = stepOnePassed.value && categories.every((group) => isIntegerWeight(primaryWeights.value[group])) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && categories.every((group) => dimensionWeightTotal(group) === 100); } if (answer?.step3Answer) { const parsed = JSON.parse(answer.step3Answer); scores.value = { ...createScores(), ...(parsed?.scores || parsed || {}) }; stepThreePassed.value = stepTwoPassed.value; } if (answer?.step4Answer) { const parsed = JSON.parse(answer.step4Answer); visualization.value = { url: parsed?.visualization?.url || "", name: parsed?.visualization?.name || "" }; stepFourPassed.value = stepThreePassed.value && Boolean(visualization.value.url); } if (answer?.dynamicStepAnswers) { const parsed = JSON.parse(answer.dynamicStepAnswers); dynamicStepAnswers.value = parsed && typeof parsed === "object" ? parsed : {}; reviewInsights.value = { ...createReviewInsights(), ...(dynamicStepAnswers.value?.step5?.reviewInsights || {}) }; developmentConclusion.value = String(dynamicStepAnswers.value?.step6?.developmentConclusion || ""); stepFivePassed.value = stepFourPassed.value && hasCompleteReviewInsights(); } const savedStep = Number(answer?.currentStep) || 1; currentStep.value = stepFivePassed.value ? Math.min(savedStep, 6) : stepFourPassed.value ? Math.min(savedStep, 5) : stepThreePassed.value ? Math.min(savedStep, 4) : stepTwoPassed.value ? Math.min(savedStep, 3) : stepOnePassed.value ? Math.min(savedStep, 2) : 1; } catch (error) { /* Keep the page available when history cannot be restored. */ } }
+async function loadSavedAnswer() { try { const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data; if (answer?.step1Answer) { const parsed = JSON.parse(answer.step1Answer); answers.value = { ...createAnswers(), ...(parsed?.answers || parsed || {}) }; stepOnePassed.value = indicators.every((item) => answers.value[item.id] === item.group); } if (answer?.step2Answer) { const parsed = JSON.parse(answer.step2Answer); primaryWeights.value = { ...createPrimaryWeights(), ...(parsed?.primaryWeights || {}) }; weights.value = { ...createWeights(), ...(parsed?.dimensionWeights || parsed?.weights || {}) }; stepTwoPassed.value = stepOnePassed.value && categories.every((group) => isIntegerWeight(primaryWeights.value[group])) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && categories.every((group) => dimensionWeightTotal(group) === 100); } if (answer?.step3Answer) { const parsed = JSON.parse(answer.step3Answer); scores.value = { ...createScores(), ...(parsed?.scores || parsed || {}) }; stepThreePassed.value = stepTwoPassed.value; } if (answer?.step4Answer) { const parsed = JSON.parse(answer.step4Answer); visualization.value = { url: parsed?.visualization?.url || "", name: parsed?.visualization?.name || "" }; stepFourPassed.value = stepThreePassed.value && Boolean(visualization.value.url); } if (answer?.dynamicStepAnswers) { const parsed = JSON.parse(answer.dynamicStepAnswers); dynamicStepAnswers.value = parsed && typeof parsed === "object" ? parsed : {}; reviewInsights.value = { ...createReviewInsights(), ...(dynamicStepAnswers.value?.step5?.reviewInsights || {}) }; developmentConclusion.value = String(dynamicStepAnswers.value?.step6?.developmentConclusion || ""); stepFivePassed.value = stepFourPassed.value && hasCompleteReviewInsights(); } const savedStep = Number(answer?.currentStep) || 1; currentStep.value = stepFivePassed.value ? Math.min(savedStep, 6) : stepFourPassed.value ? Math.min(savedStep, 5) : stepThreePassed.value ? Math.min(savedStep, 4) : stepTwoPassed.value ? Math.min(savedStep, 3) : stepOnePassed.value ? Math.min(savedStep, 2) : 1; } catch (error) { /* Keep the page available when history cannot be restored. */ } }
function buildStepOneOutcome() { return { title: taskTitle.value, answers: { ...answers.value } }; }
function buildStepTwoOutcome() { return { title: taskTitle.value, primaryWeights: { ...primaryWeights.value }, dimensionWeights: { ...weights.value }, correspondingWeights: Object.fromEntries(indicators.map((item) => [item.id, correspondingWeight(item)])) }; }
function buildStepThreeOutcome() { return { title: taskTitle.value, scores: { ...scores.value } }; }
@@ -113,12 +113,13 @@ function buildStepSixOutcome() { return { title: taskTitle.value, developmentCon
function buildDynamicStepAnswers(overrides) { return { ...dynamicStepAnswers.value, ...overrides }; }
async function checkAndContinue() { if (saving.value) return; saving.value = true; try { const result = await checkMarketOpportunitySelectionStepOne(indicators.map((item) => ({ id: item.id, category: answers.value[item.id] }))); stepOneCheckResults.value = Object.fromEntries((result?.data?.items || []).map((item) => [item.id, item])); if (!result?.data?.allCorrect) throw new Error(`当前答对 ${result?.data?.correctCount || 0}/${indicators.length} 项,请根据表内提示修改`); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }); stepOnePassed.value = true; currentStep.value = 2; proxy?.$modal?.msgSuccess("分类正确,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成指标分类并根据提示修改"); } finally { saving.value = false; } }
function isIntegerWeight(value) { return value !== null && value !== "" && Number.isInteger(Number(value)) && Number(value) >= 1 && Number(value) <= 100; }
-function dimensionWeightTotal(group) { return indicators.filter((item) => answers.value[item.id] === group).reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0); }
-function correspondingWeight(item) { return roundWeight(((Number(primaryWeights.value[answers.value[item.id]]) || 0) * (Number(weights.value[item.id]) || 0)) / 100); }
+function dimensionWeightTotal(group) { return indicators.filter((item) => item.group === group).reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0); }
+function correspondingWeight(item) { return roundWeight(((Number(primaryWeights.value[item.group]) || 0) * (Number(weights.value[item.id]) || 0)) / 100); }
function roundWeight(value) { return Math.round((value + Number.EPSILON) * 100) / 100; }
function formatWeight(value) { return Number.isInteger(value) ? value : value.toFixed(2); }
+function isStepTwoReady() { return categories.every((group) => isIntegerWeight(primaryWeights.value[group]) && dimensionWeightTotal(group) === 100) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && correspondingWeightTotal.value === 100; }
function switchStep(step) { if (step > 1 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成第 1 步指标分类"); return; } if (step > 2 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成第 2 步指标权重设定"); return; } if (step > 3 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成第 3 步综合得分计算"); return; } if (step > 4 && !stepFourPassed.value) { proxy?.$modal?.msgWarning("请先完成第 4 步数据可视化"); return; } if (step > 5 && !stepFivePassed.value) { proxy?.$modal?.msgWarning("请先完成第 5 步用户需求洞察"); return; } currentStep.value = step; }
-async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepTwo({ primaryWeights: categories.map((group) => ({ group, weight: primaryWeights.value[group] })), dimensionWeights: indicators.map((item) => ({ id: item.id, weight: weights.value[item.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("权重校验通过,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请确认一级指标、维度内权重和对应总权重均满足规则"); } finally { saving.value = false; } }
+async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; if (!isStepTwoReady()) { proxy?.$modal?.msgError?.("请确认一级指标、维度内权重和对应总权重均为 100%"); return; } saving.value = true; try { await checkMarketOpportunitySelectionStepTwo({ primaryWeights: categories.map((group) => ({ group, weight: primaryWeights.value[group] })), dimensionWeights: indicators.map((item) => ({ id: item.id, weight: weights.value[item.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("权重校验通过,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请确认一级指标、维度内权重和对应总权重均满足规则"); } finally { saving.value = false; } }
async function saveAndGoToStepFour() { if (saving.value || !stepTwoPassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepThree({ scores: products.map((product) => ({ id: product.id, score: scores.value[product.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreePassed.value = true; currentStep.value = 4; proxy?.$modal?.msgSuccess("综合得分校验通过,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写 0 至 100 分、最多保留两位小数的综合得分"); } finally { saving.value = false; } }
function clearStepOneCheck(id) { if (stepOneCheckResults.value[id]) { const next = { ...stepOneCheckResults.value }; delete next[id]; stepOneCheckResults.value = next; } }
function resetStepOne() { answers.value = createAnswers(); stepOneCheckResults.value = {}; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
diff --git a/tests/market-opportunity-step2.static.test.cjs b/tests/market-opportunity-step2.static.test.cjs
new file mode 100644
index 0000000..d76eb38
--- /dev/null
+++ b/tests/market-opportunity-step2.static.test.cjs
@@ -0,0 +1,59 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+
+const source = fs.readFileSync(path.join(__dirname, "../src/views/product/market-opportunity-selection.vue"), "utf8");
+
+test("第二步按指定顺序渲染固定权重维度,并实时展示每个维度合计", () => {
+ const expectedIds = [
+ "search-count", "transaction-amount", "transaction-growth",
+ "category-transaction-growth", "demand-supply-ratio", "online-products",
+ "online-merchants", "organic-traffic-ratio", "return-rate",
+ ];
+ const actualIds = [...source.matchAll(/\{ id: "([^"]+)"/g)].slice(0, 9).map((match) => match[1]);
+
+ assert.deepEqual(actualIds, expectedIds);
+ assert.match(source, /items:\s*indicators\.filter\(\(item\) => item\.group === name\)/);
+ assert.match(source, /维度内权重合计\s*]*>\{\{ dimensionWeightTotal\(group\.name\) \}\}%<\/strong>/);
+});
+
+test("第二步以固定分组计算对应总权重,并在保存前要求所有合计均为 100%", () => {
+ assert.match(source, /primaryWeights\.value\[item\.group\]/);
+ const readiness = source.match(/function isStepTwoReady\(\) \{([^}]*)\}/)?.[1] || "";
+ assert.match(readiness, /primaryWeightTotal\.value === 100/);
+ assert.match(readiness, /correspondingWeightTotal\.value === 100/);
+ assert.match(readiness, /dimensionWeightTotal\(group\) === 100/);
+ assert.match(source, /if \(!isStepTwoReady\(\)\) \{ proxy\?\.\$modal\?\.msgError\?\.\("请确认一级指标、维度内权重和对应总权重均为 100%"\); return; \}/);
+});
+
+test("第二步为全部一级和二级指标提供题目规定的建议区间与固定分组", () => {
+ const expectedPrimarySuggestions = {
+ "市场规模": "25–35", "市场潜力": "25–35", "竞争强度": "20–30", "运营难度": "10–20",
+ };
+ const expectedMetricGroups = {
+ "search-count": "市场规模", "transaction-amount": "市场规模",
+ "transaction-growth": "市场潜力", "category-transaction-growth": "市场潜力",
+ "demand-supply-ratio": "竞争强度", "online-products": "竞争强度", "online-merchants": "竞争强度",
+ "organic-traffic-ratio": "运营难度", "return-rate": "运营难度",
+ };
+ const expectedMetricSuggestions = ["25–35", "65–75", "55–65", "35–45", "25–35", "35–45", "25–35", "55–65", "35–45"];
+ const metricEntries = [...source.matchAll(/\{ id: "([^"]+)", name: "[^"]+", group: "([^"]+)", suggestion: "([^"]+)" \}/g)].slice(0, 9);
+
+ assert.deepEqual(Object.fromEntries(metricEntries.map(([, id, group]) => [id, group])), expectedMetricGroups);
+ assert.deepEqual(metricEntries.map(([, , , suggestion]) => suggestion), expectedMetricSuggestions);
+ for (const [group, suggestion] of Object.entries(expectedPrimarySuggestions)) {
+ assert.match(source, new RegExp(`"${group}": "${suggestion}"`));
+ }
+});
+
+test("第二步的对应总权重列仅显示计算结果,不提供可编辑输入", () => {
+ const weightTable = source.match(/