|
|
|
|
@ -56,7 +56,7 @@
|
|
|
|
|
<tfoot><tr><th colspan="2">当前权重合计</th><td :class="{ 'total-error': weightTotal !== 100 }">{{ displayWeightTotal }}%</td><td></td></tr></tfoot>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="hint">默认展示建议的评估维度和权重占比。可修改任意单元格,也可逐行新增、删除;点击下一步时系统校验所有权重之和必须等于 100%。</p>
|
|
|
|
|
<p class="hint">默认展示建议的评估维度和建议权重范围,具体权重需由学生自行填写。可修改任意单元格,也可逐行新增、删除;点击下一步时系统校验所有权重之和必须等于 100%。</p>
|
|
|
|
|
<footer class="footer-actions">
|
|
|
|
|
<el-button :loading="saving" @click="saveWeightStep">保存当前进度</el-button>
|
|
|
|
|
<el-button type="primary" :loading="validating" @click="goStepTwo">下一步<el-icon><ArrowRight /></el-icon></el-button>
|
|
|
|
|
@ -72,7 +72,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<table class="channel-table">
|
|
|
|
|
<thead><tr><th>渠道</th><th>日均总搜索量</th><th>供需比</th><th>平均点击率(%)</th><th>平均CPC(元)</th><th>平均转化率(%)</th><th>现有相关访客数</th><th>匹配度(1-5)</th><th class="operation-column">操作</th></tr></thead>
|
|
|
|
|
<thead><tr><th>渠道</th><th>日均总搜索量</th><th>供需比</th><th>平均点击率(%)</th><th>平均CPC(元)</th><th>平均转化率(%)</th><th>现有相关访客数</th><th>匹配度(1-100)</th><th class="operation-column">操作</th></tr></thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="(row, index) in channelRows" :key="row.id">
|
|
|
|
|
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
|
|
|
|
|
@ -82,7 +82,7 @@
|
|
|
|
|
<td><el-input-number v-model="row.averageCpc" :min="0" :precision="2" controls-position="right" /></td>
|
|
|
|
|
<td><el-input-number v-model="row.averageConversionRate" :min="0" :max="100" :precision="2" controls-position="right" /></td>
|
|
|
|
|
<td><el-input-number v-model="row.existingVisitorCount" :min="0" :precision="0" controls-position="right" /></td>
|
|
|
|
|
<td><el-input-number v-model="row.matchingScore" :min="1" :max="5" :precision="0" controls-position="right" /></td>
|
|
|
|
|
<td><el-input-number v-model="row.matchingScore" :min="1" :max="100" :precision="0" controls-position="right" /></td>
|
|
|
|
|
<td class="operation-column"><el-button link type="danger" @click="channelRows.splice(index, 1)">删除本行</el-button></td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr v-if="!channelRows.length"><td colspan="9" class="empty-row">暂无渠道数据,请增加一行后填写。</td></tr>
|
|
|
|
|
@ -99,6 +99,7 @@
|
|
|
|
|
|
|
|
|
|
<section v-else-if="activeStep === 3">
|
|
|
|
|
<p class="scoring-guide">通过 Min-Max 归一法将各维度原始数据转换为 0–10 分标准化得分。正向指标(搜索量、点击率、转化率、访客数、匹配度)按“渠道值 − 最小值”计算;逆向指标(供需比、CPC)按“最大值 − 渠道值”计算。请根据前两步数据手工填写得分与加权得分。</p>
|
|
|
|
|
<div class="table-actions"><el-button type="primary" plain @click="exportScoreRows"><el-icon><Download /></el-icon>导出</el-button></div>
|
|
|
|
|
<div class="table-wrap scoring-table-wrap">
|
|
|
|
|
<table class="scoring-table">
|
|
|
|
|
<thead>
|
|
|
|
|
@ -124,8 +125,8 @@
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;下一步会按已保存的前两步数据重新计算并校验。</p>
|
|
|
|
|
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。系统会校验加权得分,填写不正确不能进入下一步。</p>
|
|
|
|
|
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;系统会依据已保存的前两步数据检测计算结果。</p>
|
|
|
|
|
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。计算结果不一致时仍可进入下一步,系统会记录检测结果,供 AI 助评扣分参考。</p>
|
|
|
|
|
<section class="step-three-materials" aria-label="归一化评分参考资料">
|
|
|
|
|
<p>参考方法及工具如下:下载案例数据表,使用 Excel 按 Min-Max 公式计算各渠道归一化得分,可参考《实训操作说明》完成。</p>
|
|
|
|
|
<el-button type="primary" class="step-three-materials__button" @click="downloadPracticeGuide"><el-icon><Download /></el-icon>下载实训操作说明</el-button>
|
|
|
|
|
@ -186,8 +187,8 @@ const validating = ref(false);
|
|
|
|
|
let nextId = 1;
|
|
|
|
|
const makeRow = (values = {}) => ({ id: nextId++, ...values });
|
|
|
|
|
const defaultWeightRows = () => [
|
|
|
|
|
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", 20, "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", 15, "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", 15, "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", 15, "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", 15, "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", 10, "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", 10, "5~10"],
|
|
|
|
|
].map(([dimension, description, weight, suggestion]) => makeRow({ dimension, description, weight, suggestion }));
|
|
|
|
|
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", "5~10"],
|
|
|
|
|
].map(([dimension, description, suggestion]) => makeRow({ dimension, description, weight: undefined, suggestion }));
|
|
|
|
|
const defaultChannelRows = () => ["天猫", "京东", "抖音", "拼多多", "唯品会"].map((channel) => makeRow({ channel }));
|
|
|
|
|
const weightSuggestions = Object.fromEntries(defaultWeightRows().map((row) => [row.dimension, row.suggestion]));
|
|
|
|
|
const weightRows = ref(defaultWeightRows());
|
|
|
|
|
@ -215,6 +216,12 @@ const progressItems = computed(() => parseTrainingArray(taskConfig.value?.steps)
|
|
|
|
|
const weightPayload = () => ({ rows: weightRows.value.map(({ dimension, description, weight }) => ({ dimension, description, weight })) });
|
|
|
|
|
const channelPayload = () => ({ rows: channelRows.value.map(({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore }) => ({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore })) });
|
|
|
|
|
const scorePayload = () => ({ rows: scoreRows.value.map((row) => ({ channel: row.channel, weightedScore: row.weightedScore, dimensions: row.dimensions.map(({ dimension, score, weight }) => ({ dimension, score, weight })) })) });
|
|
|
|
|
const scoreAnswerPayload = (calculationCheck = {}) => ({ ...scorePayload(), calculationCheck: {
|
|
|
|
|
calculationCorrect: calculationCheck.calculationCorrect === true,
|
|
|
|
|
totalItemCount: Number(calculationCheck.totalItemCount) || 0,
|
|
|
|
|
incorrectItemCount: Number(calculationCheck.incorrectItemCount) || 0,
|
|
|
|
|
incorrectItems: Array.isArray(calculationCheck.incorrectItems) ? calculationCheck.incorrectItems : [],
|
|
|
|
|
} });
|
|
|
|
|
const rankingPayload = () => ({ rows: rankingRows.value.map(({ channel, weightedScore }) => ({ channel, weightedScore })), coreChannelAnalysis: coreChannelAnalysis.value });
|
|
|
|
|
function addWeightRow() { weightRows.value.push(makeRow({ dimension: "", description: "", weight: undefined, suggestion: "" })); }
|
|
|
|
|
function addChannelRow() { channelRows.value.push(makeRow({ channel: "" })); }
|
|
|
|
|
@ -226,7 +233,7 @@ async function validateChannelStep() { if (validating.value) return; validating.
|
|
|
|
|
function syncScoreRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); const weights = Object.fromEntries(weightRows.value.map((row) => [row.dimension, row.weight])); scoreRows.value = channelRows.value.map((channelRow) => { const saved = savedByChannel.get(channelRow.channel); const savedScores = new Map((saved?.dimensions || []).map((dimension) => [dimension?.dimension, dimension])); return makeRow({ channel: channelRow.channel, weightedScore: saved?.weightedScore, dimensions: scoringMetrics.map((metric) => makeRow({ key: metric.key, dimension: metric.dimension, weight: weights[metric.dimension], score: savedScores.get(metric.dimension)?.score })) }); }); }
|
|
|
|
|
function openStepThree() { syncScoreRows(scoreRows.value); activeStep.value = 3; }
|
|
|
|
|
async function saveScoreStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(3, { step3Answer: JSON.stringify(scorePayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
|
|
|
|
|
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepThree(scorePayload()); await saveAnswer(4, { step3Answer: JSON.stringify(scorePayload()) }); syncRankingRows(); activeStep.value = 4; ElMessage.success("归一化得分与加权得分校验通过,已进入第四步"); } catch (error) { ElMessage.error(error?.message || "请检查归一化得分和加权得分"); } finally { validating.value = false; } }
|
|
|
|
|
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { const result = await validateReleaseChannelEvaluationStepThree(scorePayload()); const calculationCheck = result?.data || {}; await saveAnswer(4, { step3Answer: JSON.stringify(scoreAnswerPayload(calculationCheck)) }); syncRankingRows(); activeStep.value = 4; ElMessage[calculationCheck.calculationCorrect ? "success" : "warning"](calculationCheck.message || "已保存并进入第四步"); } catch (error) { ElMessage.error(error?.message || "请完整填写归一化得分表"); } finally { validating.value = false; } }
|
|
|
|
|
function syncRankingRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); rankingRows.value = scoreRows.value.map((scoreRow) => { const saved = savedByChannel.get(scoreRow.channel); return makeRow({ channel: saved?.channel ?? "", weightedScore: saved?.weightedScore }); }); }
|
|
|
|
|
function openStepFour() { syncRankingRows(rankingRows.value); activeStep.value = 4; }
|
|
|
|
|
async function saveRankingStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
|
|
|
|
|
@ -242,7 +249,9 @@ function downloadBuiltInFile(url, fileName) {
|
|
|
|
|
link.click();
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
}
|
|
|
|
|
function exportChannelRows() { const headers = ["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-5)"]; const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore])].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = "产品发布渠道评估-渠道数据.csv"; link.click(); URL.revokeObjectURL(url); }
|
|
|
|
|
function downloadCsv(headers, rows, fileName) { const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...rows].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }
|
|
|
|
|
function exportChannelRows() { downloadCsv(["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-100)"], channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore]), "产品发布渠道评估-渠道数据.csv"); }
|
|
|
|
|
function exportScoreRows() { const headers = ["渠道", ...scoringMetrics.flatMap((metric) => [`${metric.dimension}得分`, `${metric.dimension}权重(%)`]), "加权得分"]; const rows = scoreRows.value.map((row) => [row.channel, ...row.dimensions.flatMap((dimension) => [dimension.score, dimension.weight]), row.weightedScore]); downloadCsv(headers, rows, "产品发布渠道评估-归一化评分.csv"); }
|
|
|
|
|
function restoreRows(raw, target, defaults, fields) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw; if (!Array.isArray(saved?.rows)) return; target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); return makeRow(target === weightRows ? { ...value, suggestion: weightSuggestions[value.dimension] || "" } : value); }); } catch (error) { target.value = defaults(); } }
|
|
|
|
|
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data; restoreRows(answer?.step1Answer, weightRows, defaultWeightRows, ["dimension", "description", "weight"]); restoreRows(answer?.step2Answer, channelRows, defaultChannelRows, ["channel", "dailySearchVolume", "supplyDemandRatio", "averageClickRate", "averageCpc", "averageConversionRate", "existingVisitorCount", "matchingScore"]); let savedScores = []; try { const saved = typeof answer?.step3Answer === "string" ? JSON.parse(answer.step3Answer || "{}") : answer?.step3Answer; savedScores = Array.isArray(saved?.rows) ? saved.rows : []; } catch (error) { savedScores = []; } syncScoreRows(savedScores); let savedRanking = []; try { const saved = typeof answer?.step4Answer === "string" ? JSON.parse(answer.step4Answer || "{}") : answer?.step4Answer; savedRanking = Array.isArray(saved?.rows) ? saved.rows : []; coreChannelAnalysis.value = String(saved?.coreChannelAnalysis || ""); } catch (error) { savedRanking = []; coreChannelAnalysis.value = ""; } syncRankingRows(savedRanking); activeStep.value = Math.min(Math.max(Number(answer?.currentStep) || 1, 1), 4); } catch (error) { syncScoreRows(); syncRankingRows(); } }
|
|
|
|
|
onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = result?.data || null; } catch (error) { taskConfig.value = null; } await loadProgress(); });
|
|
|
|
|
|