diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js
index 218647b..7b3bc46 100644
--- a/src/api/studentTrainingAnswer.js
+++ b/src/api/studentTrainingAnswer.js
@@ -401,12 +401,19 @@ export function validateReleaseChannelEvaluationStepFour(payload) {
export function validateProductDiagnosisIterationStepOne(payload) {
const value = payload || {};
+ const submittedChannels = Array.isArray(value.channels) ? value.channels : null;
const conversionRates = Array.isArray(value.conversionRates) ? value.conversionRates : [];
const anomalyRows = Array.isArray(value.anomalyRows) ? value.anomalyRows : [];
if (isStudentDemo()) {
try {
const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"];
- const channels = ["天猫", "京东", "抖音", "拼多多"];
+ const legacyChannels = [{ id: "tmall", name: "天猫" }, { id: "jd", name: "京东" }, { id: "douyin", name: "抖音" }, { id: "pinduoduo", name: "拼多多" }];
+ const channels = (submittedChannels || legacyChannels).map((channel) => ({ id: String(channel?.id || "").trim(), name: String(channel?.name || "").trim() }));
+ if (!channels.length || channels.some((channel) => !channel.id || !channel.name)
+ || new Set(channels.map((channel) => channel.id)).size !== channels.length
+ || new Set(channels.map((channel) => channel.name)).size !== channels.length) {
+ throw new Error("请至少保留一个渠道,并确保渠道名称不为空且不重复");
+ }
if (conversionRates.length !== nodes.length || new Set(conversionRates.map((row) => String(row?.linkNode || "").trim())).size !== nodes.length) {
throw new Error("请完整填写 5 个链路节点的转化率");
}
@@ -414,19 +421,22 @@ export function validateProductDiagnosisIterationStepOne(payload) {
conversionRates.forEach((row) => {
const node = String(row?.linkNode || "").trim();
if (!nodes.includes(node)) throw new Error("链路节点必须与题目中的 5 个节点一致");
- const values = [row?.tmall, row?.jd, row?.douyin, row?.pinduoduo, row?.industryAverage].map(Number);
+ const channelRates = row?.channelRates || row || {};
+ const rawValues = [...channels.map((channel) => channelRates[channel.id]), row?.industryAverage];
+ if (rawValues.some((item) => item === null || item === "" || typeof item === "undefined")) throw new Error("请填写 0 至 100 之间的转化率");
+ const values = rawValues.map(Number);
if (values.some((item) => !Number.isFinite(item) || item < 0 || item > 100)) throw new Error("请填写 0 至 100 之间的转化率");
- rates.set(node, { ...row, values });
+ rates.set(node, { ...row, channelRates });
});
if (!String(value.funnelChartUrl || "").trim()) throw new Error("请上传节点转化漏斗对比图");
if (anomalyRows.length < 1 || anomalyRows.length > 5) throw new Error("异常节点分析最少填写 1 行,最多填写 5 行");
anomalyRows.forEach((row, index) => {
- const channel = String(row?.channel || "").trim();
+ const channel = channels.find((item) => item.id === String(row?.channelId || "").trim()) || channels.find((item) => item.name === String(row?.channel || "").trim());
const node = String(row?.linkNode || "").trim();
const source = rates.get(node);
- if (!channels.includes(channel)) throw new Error(`第 ${index + 1} 行渠道必须来源于第一个表格`);
+ if (!channel) throw new Error(`第 ${index + 1} 行渠道必须来源于第一个表格`);
if (!source) throw new Error(`第 ${index + 1} 行链路节点必须来源于第一个表格`);
- const sourceRate = source[{ 天猫: "tmall", 京东: "jd", 抖音: "douyin", 拼多多: "pinduoduo" }[channel]];
+ const sourceRate = source.channelRates[channel.id];
if (Number(row?.storeConversionRate) !== Number(sourceRate) || Number(row?.industryAverageConversionRate) !== Number(source.industryAverage)) {
throw new Error(`第 ${index + 1} 行的店铺和行业转化率必须与第一个表格对应数据一致`);
}
@@ -438,7 +448,7 @@ export function validateProductDiagnosisIterationStepOne(payload) {
return request({
url: "/api/student-training-answers/product-diagnosis-iteration/step-1/validate",
method: "post",
- data: { conversionRates, funnelChartUrl: value.funnelChartUrl, anomalyRows },
+ data: { channels: submittedChannels, conversionRates, funnelChartUrl: value.funnelChartUrl, anomalyRows },
headers: { repeatSubmit: false },
});
}
diff --git a/src/views/assessment/diagnosis.vue b/src/views/assessment/diagnosis.vue
index 40149e4..f569136 100644
--- a/src/views/assessment/diagnosis.vue
+++ b/src/views/assessment/diagnosis.vue
@@ -14,9 +14,43 @@
2.下载Excel模版,填写关键信息,自动计算各环节转化率。提供自动化模版。
3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成计算,参考步骤及提示词如下:请根据表格数据,计算从曝光到复购链路节点转化率。
- 一计算各节点转化率
根据案例数据计算各个渠道链路节点转化率,参考方法及工具如下:
| 链路节点 | 天猫 | 京东 | 抖音 | 拼多多 | 行业均值 |
|---|
| {{ row.linkNode }} | % |
|---|
+
+ 一计算各节点转化率
根据案例数据计算各个渠道链路节点转化率,参考方法及工具如下:
+
+ 链路节点和行业均值固定,中间渠道可按案例数据增加或删除。
+
+
+
+
+
+
+ | 链路节点 |
+
+
+
+
+
+ |
+ 行业均值 |
+
+
+
+
+ | {{ row.linkNode }} |
+
+
+
+ %
+
+ |
+ % |
+
+
+
+
+
二全链路转化漏斗可视化
根据节点转化率计算结果,绘制各渠道转化率与行业均值的转化漏斗对比图,可视化呈现多环节情况,参考方法及工具如下:
1.使用Excel绘图功能,自主完成转化漏斗对比图,可参考《实训操作说明》。 2.下载Excel模版,填写关键信息,自动计算各环节转化率并生成转化漏斗对比图。 3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成,参考步骤及提示词如下:请根据表格数据绘制漏斗图,并与行业均值漏斗图进行对比。
{{ funnelChart.name || '节点转化漏斗对比图' }}点击缩略图可放大预览
支持 PNG、JPG、WEBP 格式,图片不超过 5MB;上传后可点击预览。
- | 渠道 | 链路节点 | 店铺转化率 | 行业均值转化率 | 异常分析与判定 |
|---|
| | % | % | |
说明:这个表格默认展示一行可填写,最少一行,可以增加行,最多5行,这里填写的渠道需要来源于第一个表的渠道。
+ | 渠道 | 链路节点 | 店铺转化率 | 行业均值转化率 | 异常分析与判定 |
|---|
| | % | % | |
说明:这个表格默认展示一行可填写,最少一行,可以增加行,最多5行,这里填写的渠道需要来源于第一个表的渠道。
@@ -65,14 +99,60 @@ function downloadFunnelGuide() { downloadBuiltInFile(FUNNEL_GUIDE_URL); }
function downloadFunnelTemplate() { downloadBuiltInFile(FUNNEL_TEMPLATE_URL); }
function downloadAbGuide() { downloadBuiltInFile(AB_TEST_GUIDE_URL); }
function downloadAbTemplate() { downloadBuiltInFile(AB_TEST_TEMPLATE_URL); }
-const TASK_KEY = "product-diagnosis-iteration"; const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"]; const channels = [{ key: "tmall", label: "天猫" }, { key: "jd", label: "京东" }, { key: "douyin", label: "抖音" }, { key: "pinduoduo", label: "拼多多" }]; const rateFields = [...channels.map((item) => item.key), "industryAverage"];
+const TASK_KEY = "product-diagnosis-iteration";
+const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"];
+const defaultChannels = [{ id: "tmall", name: "天猫" }, { id: "jd", name: "京东" }, { id: "douyin", name: "抖音" }, { id: "pinduoduo", name: "拼多多" }];
+let channelSerial = 0;
const { proxy } = getCurrentInstance(); const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const analyzing = ref(false); const chartInput = ref(null); const commentInput = ref(null); const abChartInput = ref(null); const wordCloudRef = ref(null); let wordCloudChart = null;
+const channels = ref(createDefaultChannels());
const conversionRates = ref(createConversionRates()); const anomalyRows = ref([createAnomalyRow()]); const funnelChart = ref({ url: "", name: "" }); const analysisResult = ref(null); const commentPreview = ref({ fileName: "", totalComments: 0, comments: [] }); const wordCloud = ref([]); const topIssues = ref(["", "", ""]); const sentiments = ref(createSentiments()); const rootCauseAnalysis = ref(""); const abTestPlan = ref(""); const abRows = ref(createAbRows()); const abChart = ref({ url: "", name: "" }); const iterationRecommendation = ref("");
const taskTitle = computed(() => taskConfig.value?.taskName || "产品问题诊断与迭代"); const taskBackground = computed(() => taskConfig.value?.background || "从曝光到复购的完整数据漏斗与用户评价出发,定位异常流失并形成迭代方向。"); const taskRequirement = computed(() => taskConfig.value?.requirements || "完成转化漏斗诊断、用户评论词云和情感根因分析。"); const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["掌握全链路转化率计算方法", "能够将店铺数据与行业均值进行对比", "从用户评价中定位问题根因"]));
-const progressItems = computed(() => currentStep.value === 1 ? [{ text: "填写五个节点的渠道转化率", status: conversionRates.value.every((row) => rateFields.every((field) => row[field] !== null && row[field] !== "" && Number.isFinite(Number(row[field])))) ? "done" : "doing" }, { text: "上传节点转化漏斗对比图", status: funnelChart.value.url ? "done" : "" }, { text: "完成异常节点分析", status: anomalyRows.value.every((row) => row.anomalyAnalysis?.trim()) ? "done" : "" }] : currentStep.value === 2 ? [{ text: "导入评论数据并生成词云", status: analysisResult.value ? "done" : "doing" }, { text: "完成情感倾向分析", status: sentiments.value.every((row) => row.count !== null) ? "done" : "" }, { text: "填写根因分析", status: rootCauseAnalysis.value.trim() ? "done" : "" }] : [{ text: "设计 AB 测试方案", status: abTestPlan.value.trim() ? "done" : "doing" }, { text: "填写五项对照数据", status: abRows.value.every((row) => row.controlValue !== null && row.experimentValue !== null && row.analysis?.trim()) ? "done" : "" }, { text: "上传 AB 对比图", status: abChart.value.url ? "done" : "" }, { text: "提交迭代优化建议", status: iterationRecommendation.value.trim() ? "done" : "" }]);
-function createConversionRates() { return nodes.map((linkNode) => ({ linkNode, tmall: null, jd: null, douyin: null, pinduoduo: null, industryAverage: null })); } function createAnomalyRow() { return { channel: "", linkNode: "", storeConversionRate: null, industryAverageConversionRate: null, anomalyAnalysis: "" }; } function createSentiments() { return ["正面", "中性", "负面"].map((type) => ({ type, count: null, percentage: null })); } function createAbRows() { return ["点击-加购转化率", "加购-下单转化率", "下单-好评转化率", "详情页跳出率", "退货率"].map((metric) => ({ metric, controlValue: null, experimentValue: null, analysis: "" })); }
-function buildStepOne() { return { conversionRates: conversionRates.value.map((row) => ({ ...row })), funnelChartUrl: funnelChart.value.url, funnelChartName: funnelChart.value.name, anomalyRows: anomalyRows.value.map((row) => ({ ...row })) }; } function buildStepTwo() { return { sourceFileName: commentPreview.value.fileName, topIssues: [...topIssues.value], sentiments: sentiments.value.map((row) => ({ ...row })), rootCauseAnalysis: rootCauseAnalysis.value, wordCloud: wordCloud.value, previewComments: commentPreview.value.comments }; } function buildStepThree() { return { testPlan: abTestPlan.value, rows: abRows.value.map((row) => ({ ...row })), comparisonChartUrl: abChart.value.url, comparisonChartName: abChart.value.name, iterationRecommendation: iterationRecommendation.value }; }
-function addAnomalyRow() { if (anomalyRows.value.length < 5) anomalyRows.value.push(createAnomalyRow()); } function removeAnomalyRow() { if (anomalyRows.value.length > 1) anomalyRows.value.pop(); } function clearChart() { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); funnelChart.value = { url: "", name: "" }; }
+const availableChannels = computed(() => channels.value.filter((channel) => channel.name.trim()));
+const funnelRatesComplete = computed(() => {
+ const names = availableChannels.value.map((channel) => channel.name.trim());
+ if (!channels.value.length || names.length !== channels.value.length || new Set(names).size !== names.length) return false;
+ return conversionRates.value.every((row) => isRate(row.industryAverage) && channels.value.every((channel) => isRate(row.channelRates[channel.id])));
+});
+const progressItems = computed(() => currentStep.value === 1 ? [{ text: "填写五个节点的渠道转化率", status: funnelRatesComplete.value ? "done" : "doing" }, { text: "上传节点转化漏斗对比图", status: funnelChart.value.url ? "done" : "" }, { text: "完成异常节点分析", status: anomalyRows.value.every((row) => row.anomalyAnalysis?.trim()) ? "done" : "" }] : currentStep.value === 2 ? [{ text: "导入评论数据并生成词云", status: analysisResult.value ? "done" : "doing" }, { text: "完成情感倾向分析", status: sentiments.value.every((row) => row.count !== null) ? "done" : "" }, { text: "填写根因分析", status: rootCauseAnalysis.value.trim() ? "done" : "" }] : [{ text: "设计 AB 测试方案", status: abTestPlan.value.trim() ? "done" : "doing" }, { text: "填写五项对照数据", status: abRows.value.every((row) => row.controlValue !== null && row.experimentValue !== null && row.analysis?.trim()) ? "done" : "" }, { text: "上传 AB 对比图", status: abChart.value.url ? "done" : "" }, { text: "提交迭代优化建议", status: iterationRecommendation.value.trim() ? "done" : "" }]);
+function createDefaultChannels() { return defaultChannels.map((channel) => ({ ...channel })); }
+function createConversionRates(channelList = channels.value) { return nodes.map((linkNode) => ({ linkNode, channelRates: Object.fromEntries(channelList.map((channel) => [channel.id, null])), industryAverage: null })); }
+function createAnomalyRow() { return { channelId: "", channel: "", linkNode: "", storeConversionRate: null, industryAverageConversionRate: null, anomalyAnalysis: "" }; }
+function createSentiments() { return ["正面", "中性", "负面"].map((type) => ({ type, count: null, percentage: null })); }
+function createAbRows() { return ["点击-加购转化率", "加购-下单转化率", "下单-好评转化率", "详情页跳出率", "退货率"].map((metric) => ({ metric, controlValue: null, experimentValue: null, analysis: "" })); }
+function isRate(value) { return value !== null && value !== "" && Number.isFinite(Number(value)); }
+function buildStepOne() {
+ const channelSnapshot = channels.value.map((channel) => ({ id: channel.id, name: channel.name.trim() }));
+ return {
+ channels: channelSnapshot,
+ conversionRates: conversionRates.value.map((row) => ({ linkNode: row.linkNode, channelRates: { ...row.channelRates }, industryAverage: row.industryAverage })),
+ funnelChartUrl: funnelChart.value.url,
+ funnelChartName: funnelChart.value.name,
+ anomalyRows: anomalyRows.value.map((row) => ({ ...row, channel: channelSnapshot.find((channel) => channel.id === row.channelId)?.name || row.channel || "" })),
+ };
+}
+function buildStepTwo() { return { sourceFileName: commentPreview.value.fileName, topIssues: [...topIssues.value], sentiments: sentiments.value.map((row) => ({ ...row })), rootCauseAnalysis: rootCauseAnalysis.value, wordCloud: wordCloud.value, previewComments: commentPreview.value.comments }; }
+function buildStepThree() { return { testPlan: abTestPlan.value, rows: abRows.value.map((row) => ({ ...row })), comparisonChartUrl: abChart.value.url, comparisonChartName: abChart.value.name, iterationRecommendation: iterationRecommendation.value }; }
+function addFunnelChannel() {
+ channelSerial += 1;
+ const channel = { id: `channel-${Date.now()}-${channelSerial}`, name: "" };
+ channels.value.push(channel);
+ conversionRates.value.forEach((row) => { row.channelRates[channel.id] = null; });
+}
+function removeFunnelChannel(channelId) {
+ channels.value = channels.value.filter((channel) => channel.id !== channelId);
+ conversionRates.value.forEach((row) => { delete row.channelRates[channelId]; });
+ anomalyRows.value.forEach((row) => {
+ if (row.channelId === channelId) {
+ row.channelId = "";
+ row.channel = "";
+ row.storeConversionRate = null;
+ }
+ });
+}
+function syncAnomalyChannel(row) { row.channel = channels.value.find((channel) => channel.id === row.channelId)?.name.trim() || ""; }
+function addAnomalyRow() { if (anomalyRows.value.length < 5) anomalyRows.value.push(createAnomalyRow()); }
+function removeAnomalyRow() { if (anomalyRows.value.length > 1) anomalyRows.value.pop(); }
+function clearChart() { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); funnelChart.value = { url: "", name: "" }; }
async function handleChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearChart(); funnelChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("漏斗对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveStepOneDraft() { await persistStepOne(false); } async function validateStepOne() { if (saving.value) return; saving.value = true; try { const data = buildStepOne(); await validateProductDiagnosisIterationStepOne(data); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); currentStep.value = 2; proxy?.$modal?.msgSuccess?.("第 1 步校验通过,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查第 1 步填写内容"); } finally { saving.value = false; } } async function persistStepOne(showMessage) { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOne()), currentStep: 1, submitted: false, saveAction: "SAVE" }); if (showMessage !== false) proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function handleCommentImport(event) { const file = event.target?.files?.[0]; if (!file) return; if (!/\.(csv|txt|xls|xlsx)$/i.test(file.name) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件"); event.target.value = ""; return; } analyzing.value = true; try { const result = (await analyzeProductDiagnosisIterationStepTwo(file))?.data; if (!result) throw new Error("未获得评论分析结果"); analysisResult.value = result; commentPreview.value = { fileName: result.sourceFileName || file.name, totalComments: result.totalComments || 0, comments: result.previewComments || [] }; wordCloud.value = result.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => result.topIssues?.[index] || ""); sentiments.value = createSentiments(); await nextTick(); renderWordCloud(); proxy?.$modal?.msgSuccess?.("评论数据已导入,词云已生成"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "评论数据分析失败,请检查文件格式"); } finally { analyzing.value = false; event.target.value = ""; } }
@@ -130,7 +210,7 @@ async function resetTraining() {
try {
await deleteStudentTrainingAnswer(TASK_KEY);
clearChart(); clearAbChart(); wordCloudChart?.dispose(); wordCloudChart = null;
- conversionRates.value = createConversionRates(); anomalyRows.value = [createAnomalyRow()]; analysisResult.value = null;
+ channels.value = createDefaultChannels(); conversionRates.value = createConversionRates(); anomalyRows.value = [createAnomalyRow()]; analysisResult.value = null;
commentPreview.value = { fileName: "", totalComments: 0, comments: [] }; wordCloud.value = []; topIssues.value = ["", "", ""]; sentiments.value = createSentiments(); rootCauseAnalysis.value = "";
abTestPlan.value = ""; abRows.value = createAbRows(); iterationRecommendation.value = ""; currentStep.value = 1;
proxy?.$modal?.msgSuccess?.("已清空全部实训步骤,可以重新开始");
@@ -146,7 +226,48 @@ function exportTrainingOutcome() {
const link = document.createElement("a"); link.href = url; link.download = "产品问题诊断与迭代-实训成果.json"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess?.("实训成果已导出");
}
-function restore(answer) { try { const stepOne = JSON.parse(answer?.step1Answer || "{}"); if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) conversionRates.value = nodes.map((node) => ({ ...createConversionRates().find((row) => row.linkNode === node), ...(stepOne.conversionRates.find((row) => row?.linkNode === node) || {}) })); if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => ({ ...createAnomalyRow(), ...row })); if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" }; const stepTwo = JSON.parse(answer?.step2Answer || "{}"); if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); } const stepThree = JSON.parse(answer?.step3Answer || "{}"); if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; } currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1; } catch (error) {} }
+function restoreStepOne(stepOne) {
+ const hasSavedChannels = Array.isArray(stepOne.channels);
+ const seenIds = new Set();
+ const restoredChannels = hasSavedChannels
+ ? stepOne.channels.map((channel) => ({ id: String(channel?.id || "").trim(), name: String(channel?.name || "").trim() })).filter((channel) => {
+ if (!channel.id || seenIds.has(channel.id)) return false;
+ seenIds.add(channel.id);
+ return true;
+ })
+ : createDefaultChannels();
+ channels.value = restoredChannels;
+ if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) {
+ conversionRates.value = nodes.map((linkNode) => {
+ const saved = stepOne.conversionRates.find((row) => row?.linkNode === linkNode) || {};
+ return {
+ linkNode,
+ channelRates: Object.fromEntries(restoredChannels.map((channel) => [channel.id, saved.channelRates?.[channel.id] ?? saved[channel.id] ?? null])),
+ industryAverage: saved.industryAverage ?? null,
+ };
+ });
+ } else {
+ conversionRates.value = createConversionRates(restoredChannels);
+ }
+ if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) {
+ anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => {
+ const channelId = String(row?.channelId || restoredChannels.find((channel) => channel.name === row?.channel)?.id || "");
+ return { ...createAnomalyRow(), ...row, channelId };
+ });
+ }
+ if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" };
+}
+function restore(answer) {
+ try {
+ const stepOne = JSON.parse(answer?.step1Answer || "{}");
+ restoreStepOne(stepOne);
+ const stepTwo = JSON.parse(answer?.step2Answer || "{}");
+ if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); }
+ const stepThree = JSON.parse(answer?.step3Answer || "{}");
+ if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; }
+ currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1;
+ } catch (error) {}
+}
onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } try { restore((await getStudentTrainingAnswer(TASK_KEY))?.data); } catch (error) {} }); onBeforeUnmount(() => { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); wordCloudChart?.dispose(); });
@@ -170,6 +291,7 @@ onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TAS
.lead{margin:0 0 20px;color:#d4edf7;font-size:15px;line-height:1.9}.section-block{margin-top:20px;padding:20px;border:1px solid rgba(97,209,247,.25);border-radius:16px;background:rgba(3,28,43,.42)}.section-heading{display:flex;align-items:flex-start;gap:12px;margin-bottom:16px}.section-heading>span{display:grid;flex:0 0 30px;width:30px;height:30px;place-items:center;border-radius:8px;color:#eaffff;background:linear-gradient(135deg,#11b8e4,#1373c4);font-size:14px;font-weight:900}.section-heading h3{margin:1px 0 4px;color:#f0fbff;font-size:18px}.section-heading p{margin:0;color:var(--diagnosis-muted);font-size:13px;line-height:1.65}
.secondary-button,.table-button,.upload-button,.submit-button{min-height:36px;padding:0 13px;border:1px solid rgba(99,203,239,.58);border-radius:8px;color:#e7fbff;background:rgba(12,111,153,.46);font:inherit;font-size:13px;font-weight:800;cursor:pointer;transition:.2s ease}.secondary-button:hover,.table-button:hover,.upload-button:hover{border-color:#7deaff;background:rgba(19,145,192,.62)}.submit-button{border-color:#72e8ff;background:linear-gradient(100deg,#0d80b7,#09638f)}.submit-button:hover{filter:brightness(1.12)}button:disabled{cursor:not-allowed;opacity:.45}.table-button.ghost{border-color:rgba(163,197,211,.36);color:#bed6e0;background:rgba(75,101,112,.25)}
.table-wrap{overflow-x:auto;border:1px solid rgba(99,183,222,.43);border-radius:12px}.funnel-table,.diagnosis-table,.sentiment-table{width:100%;border-collapse:collapse}.funnel-table{min-width:900px}.diagnosis-table{min-width:1060px}.sentiment-table{min-width:640px}.funnel-table th,.funnel-table td,.diagnosis-table th,.diagnosis-table td,.sentiment-table th,.sentiment-table td{padding:11px;border-right:1px solid rgba(99,183,222,.31);border-bottom:1px solid rgba(99,183,222,.31);vertical-align:middle;text-align:center}.funnel-table thead th,.diagnosis-table thead th,.sentiment-table thead th{color:#effaff;background:linear-gradient(100deg,#0a96cc,#087daf);font-size:14px;white-space:nowrap}.funnel-table tbody th,.sentiment-table tbody th{color:#dff8ff;background:rgba(8,72,99,.34);font-weight:800}.funnel-table td,.diagnosis-table td,.sentiment-table td{background:rgba(2,18,29,.34)}.funnel-table :deep(.el-input-number),.diagnosis-table :deep(.el-input-number),.sentiment-table :deep(.el-input-number),.diagnosis-table :deep(.el-select){width:100%}.unit{margin-left:5px;color:#74ddf6;font-size:12px;font-weight:800}.auto-count{color:#82e7ff;font-size:16px;font-weight:900}
+.funnel-channel-toolbar>span{display:block}.funnel-table .fixed-column,.funnel-table tbody th{min-width:145px}.funnel-table .channel-column,.funnel-table tbody td{min-width:180px}.channel-heading{display:flex;align-items:center;gap:7px}.channel-heading :deep(.el-input){min-width:120px}.channel-heading :deep(.el-input__wrapper){background:rgba(2,45,66,.58);box-shadow:0 0 0 1px rgba(198,242,255,.42) inset}.channel-heading :deep(.el-input__inner){color:#fff;text-align:center;font-weight:800}.channel-remove{display:inline-grid;flex:0 0 24px;width:24px;height:24px;place-items:center;padding:0;border:1px solid rgba(255,164,172,.58);border-radius:6px;color:#ffd4d7;background:rgba(130,35,53,.32);font:inherit;font-size:17px;line-height:1;cursor:pointer}.channel-remove:hover,.channel-remove:focus-visible{border-color:#ff98a2;color:#fff;background:rgba(176,48,67,.5);outline:none}.rate-input{display:flex;align-items:center;gap:6px;width:100%;white-space:nowrap}.rate-input :deep(.el-input-number){flex:1 1 auto;width:calc(100% - 22px);min-width:0}.rate-input .unit{display:inline-flex;flex:0 0 auto;align-items:center;height:32px;margin-left:0;white-space:nowrap}
.table-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 12px;color:var(--diagnosis-muted);font-size:13px}.table-toolbar>div,.actions,.diagnosis-materials__actions,.ab-materials__actions{display:flex;flex-wrap:wrap;gap:8px}.actions{justify-content:center;margin-top:24px}.actions .secondary-button,.actions .submit-button{min-width:142px;min-height:42px}
.chart-upload-area,.comment-import{display:grid;place-items:center;gap:10px;min-height:190px;padding:22px;border:1px dashed rgba(113,223,255,.66);border-radius:13px;background:linear-gradient(135deg,rgba(5,47,69,.66),rgba(4,23,35,.52));text-align:center}.chart-upload-area p,.comment-import>p{margin:0;color:var(--diagnosis-muted);font-size:13px}.upload-button{min-height:42px;padding:0 18px}.chart-preview{display:flex;align-items:center;gap:16px;width:100%;text-align:left}.chart-preview :deep(.el-image){width:180px;height:110px;border:1px solid var(--diagnosis-line);border-radius:8px}.chart-preview>div{display:grid;gap:7px}.chart-preview strong{color:#e8fbff}.chart-preview span{color:var(--diagnosis-muted);font-size:13px}.chart-preview button{width:max-content;padding:0;border:0;color:#ffadb1;background:transparent;font:inherit;cursor:pointer}
.data-preview,.word-cloud-card,.top-issues,.root-cause{margin-top:16px;padding:16px;border:1px solid rgba(99,183,222,.31);border-radius:12px;background:rgba(3,25,39,.58)}.data-preview>div,.word-cloud-title{display:flex;justify-content:space-between;gap:12px;color:#e6f9ff}.data-preview span,.word-cloud-title small{color:var(--diagnosis-muted);font-size:12px}.data-preview ul{max-height:130px;margin:12px 0 0;padding-left:20px;overflow:auto;color:#c8e5f1;line-height:1.7;text-align:left}.word-cloud{height:280px}.top-issues>p,.root-cause>p{margin:0 0 13px;color:#c8e5f1;line-height:1.7}.top-issues>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.top-issues label{display:grid;gap:7px;color:#8bdff5;font-weight:800}.sentiment-actions{display:flex;align-items:center;gap:14px;margin-bottom:16px}.sentiment-actions>span{color:var(--diagnosis-muted);font-size:13px}.visual-section,.diagnosis-section{margin-top:20px}
diff --git a/tests/product-diagnosis-dynamic-channels.static.test.cjs b/tests/product-diagnosis-dynamic-channels.static.test.cjs
new file mode 100644
index 0000000..19a4dc5
--- /dev/null
+++ b/tests/product-diagnosis-dynamic-channels.static.test.cjs
@@ -0,0 +1,29 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+
+const frontendRoot = path.resolve(__dirname, "..");
+const page = fs.readFileSync(path.join(frontendRoot, "src/views/assessment/diagnosis.vue"), "utf8");
+const api = fs.readFileSync(path.join(frontendRoot, "src/api/studentTrainingAnswer.js"), "utf8");
+
+test("产品问题诊断第一步固定首尾列并支持动态增删中间渠道", () => {
+ assert.match(page, /链路节点<\/th>[\s\S]*?v-for="channel in channels"[\s\S]*? | 行业均值<\/th>/);
+ assert.match(page, /@click="addFunnelChannel"/);
+ assert.match(page, /@click="removeFunnelChannel\(channel\.id\)"/);
+ assert.match(page, /row\.channelRates\[channel\.id\]/);
+ assert.doesNotMatch(page, / | 天猫<\/th> | 京东<\/th> | 抖音<\/th> | 拼多多<\/th>/);
+});
+
+test("百分号与转化率输入框保持同行", () => {
+ assert.match(page, / [\s\S]*?%<\/span>[\s\S]*?<\/div>/);
+ assert.match(page, /\.rate-input\{[^}]*display:flex[^}]*white-space:nowrap/);
+ assert.match(page, /\.rate-input \.unit\{[^}]*white-space:nowrap/);
+});
+
+test("动态渠道随第一步答案提交并用于校验", () => {
+ assert.match(page, /channels: channelSnapshot/);
+ assert.match(page, /channelRates: \{ \.\.\.row\.channelRates \}/);
+ assert.match(api, /data: \{ channels: submittedChannels, conversionRates/);
+ assert.match(api, /source\.channelRates\[channel\.id\]/);
+});
|