fix: support dynamic diagnosis channels

bugfix/ai-assessment-evidence-rescore
chenyuan 1 month ago
parent f7ed02edee
commit e0423adead

@ -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 },
});
}

@ -14,9 +14,43 @@
<div class="diagnosis-materials__item"><strong>2.下载Excel模版,填写关键信息,自动计算各环节转化率。提供自动化模版。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadNodeTemplate">下载Excel模板</button></div></div>
<p class="diagnosis-materials__ai">3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成计算,参考步骤及提示词如下:请根据表格数据,计算从曝光到复购链路节点转化率。</p>
</div>
<section class="section-block"><div class="section-heading"><span>一</span><div><h3>计算各节点转化率</h3><p>根据案例数据计算各个渠道链路节点转化率,参考方法及工具如下:</p></div></div><div class="table-wrap"><table class="funnel-table"><thead><tr><th>链路节点</th><th>天猫</th><th>京东</th><th>抖音</th><th>拼多多</th><th>行业均值</th></tr></thead><tbody><tr v-for="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key="field"><el-input-number v-model="row[field]" :min="0" :max="100" :precision="2" :controls="false" :placeholder="row.linkNode === '曝光-点击' && field === 'tmall' ? '示例:11.5%' : '请输入'" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block">
<div class="section-heading"><span>一</span><div><h3>计算各节点转化率</h3><p>根据案例数据计算各个渠道链路节点转化率,参考方法及工具如下:</p></div></div>
<div class="table-toolbar funnel-channel-toolbar">
<span>链路节点和行业均值固定,中间渠道可按案例数据增加或删除。</span>
<button type="button" class="table-button" @click="addFunnelChannel">增加渠道</button>
</div>
<div class="table-wrap">
<table class="funnel-table">
<thead>
<tr>
<th class="fixed-column">链路节点</th>
<th v-for="channel in channels" :key="channel.id" class="channel-column">
<div class="channel-heading">
<el-input v-model="channel.name" maxlength="20" placeholder="填写渠道名称" />
<button type="button" class="channel-remove" :aria-label="`删除${channel.name || '未命名'}渠道`" @click="removeFunnelChannel(channel.id)">×</button>
</div>
</th>
<th class="fixed-column">行业均值</th>
</tr>
</thead>
<tbody>
<tr v-for="row in conversionRates" :key="row.linkNode">
<th>{{ row.linkNode }}</th>
<td v-for="(channel, channelIndex) in channels" :key="channel.id">
<div class="rate-input">
<el-input-number v-model="row.channelRates[channel.id]" :min="0" :max="100" :precision="2" :controls="false" :placeholder="row.linkNode === '曝光-点击' && channelIndex === 0 ? '示例:11.5' : '请输入'" />
<span class="unit">%</span>
</div>
</td>
<td><div class="rate-input"><el-input-number v-model="row.industryAverage" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="section-block visual-section"><div class="section-heading"><span>二</span><div><h3>全链路转化漏斗可视化</h3><p>根据节点转化率计算结果,绘制各渠道转化率与行业均值的转化漏斗对比图,可视化呈现多环节情况,参考方法及工具如下:</p></div></div><div class="diagnosis-materials" aria-label="第 2 步参考资料"><div class="diagnosis-materials__item"><strong>1.使用Excel绘图功能,自主完成转化漏斗对比图,可参考《实训操作说明》。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadFunnelGuide">下载实训操作说明</button></div></div><div class="diagnosis-materials__item"><strong>2.下载Excel模版,填写关键信息,自动计算各环节转化率并生成转化漏斗对比图。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadFunnelTemplate">下载Excel模板</button></div></div><p class="diagnosis-materials__ai">3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成,参考步骤及提示词如下:请根据表格数据绘制漏斗图,并与行业均值漏斗图进行对比。</p></div><input ref="chartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleChartUpload" /><div class="chart-upload-area"><template v-if="funnelChart.url"><div class="chart-preview"><el-image :src="funnelChart.url" :preview-src-list="[funnelChart.url]" fit="cover" preview-teleported /><div><strong>{{ funnelChart.name || '节点转化漏斗对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearChart">移除图片</button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '更换对比图' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '上传节点转化漏斗对比图' }}</button><p>支持 PNG、JPG、WEBP 格式,图片不超过 5MB;上传后可点击预览。</p></template></div></section>
<section class="section-block diagnosis-section"><div class="section-heading"><span>三</span><div><h3>定位异常节点</h3><p>对比案例行业均值数据,定位异常节点。</p></div></div><div class="table-toolbar"><span>对比案例行业均值数据,定位异常节点。</span><div><button type="button" class="table-button" :disabled="anomalyRows.length >= 5" @click="addAnomalyRow">增加一行</button><button type="button" class="table-button ghost" :disabled="anomalyRows.length <= 1" @click="removeAnomalyRow">删除一行</button></div></div><div class="table-wrap"><table class="diagnosis-table"><thead><tr><th>渠道</th><th>链路节点</th><th>店铺转化率</th><th>行业均值转化率</th><th>异常分析与判定</th></tr></thead><tbody><tr v-for="(row, index) in anomalyRows" :key="index"><td><el-select v-model="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div><p class="diagnosis-step-one-notice">说明:这个表格默认展示一行可填写,最少一行,可以增加行,最多5行,这里填写的渠道需要来源于第一个表的渠道。</p></section>
<section class="section-block diagnosis-section"><div class="section-heading"><span>三</span><div><h3>定位异常节点</h3><p>对比案例行业均值数据,定位异常节点。</p></div></div><div class="table-toolbar"><span>对比案例行业均值数据,定位异常节点。</span><div><button type="button" class="table-button" :disabled="anomalyRows.length >= 5" @click="addAnomalyRow">增加一行</button><button type="button" class="table-button ghost" :disabled="anomalyRows.length <= 1" @click="removeAnomalyRow">删除一行</button></div></div><div class="table-wrap"><table class="diagnosis-table"><thead><tr><th>渠道</th><th>链路节点</th><th>店铺转化率</th><th>行业均值转化率</th><th>异常分析与判定</th></tr></thead><tbody><tr v-for="(row, index) in anomalyRows" :key="index"><td><el-select v-model="row.channelId" placeholder="请选择" @change="syncAnomalyChannel(row)"><el-option v-for="channel in availableChannels" :key="channel.id" :label="channel.name" :value="channel.id" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><div class="rate-input"><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td><td><div class="rate-input"><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div><p class="diagnosis-step-one-notice">说明:这个表格默认展示一行可填写,最少一行,可以增加行,最多5行,这里填写的渠道需要来源于第一个表的渠道。</p></section>
</template>
<template v-else-if="currentStep === 2">
@ -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(); });
</script>
@ -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}

@ -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 class="fixed-column">链路节点<\/th>[\s\S]*?v-for="channel in channels"[\s\S]*?<th class="fixed-column">行业均值<\/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><th>抖音<\/th><th>拼多多<\/th>/);
});
test("百分号与转化率输入框保持同行", () => {
assert.match(page, /<div class="rate-input">[\s\S]*?<el-input-number[\s\S]*?<span class="unit">%<\/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\]/);
});
Loading…
Cancel
Save