feat: add market opportunity score template

master
陈沅 4 days ago
parent 5a45c63101
commit e1fc632dd4

@ -66,6 +66,7 @@
</template>
<script setup>
import * as XLSX from "xlsx";
import { Download, RefreshLeft, UploadFilled } from "@element-plus/icons-vue";
import { checkMarketOpportunitySelectionStepOne, checkMarketOpportunitySelectionStepTwo, checkMarketOpportunitySelectionStepThree, checkMarketOpportunitySelectionStepFour, checkMarketOpportunitySelectionStepFive, checkMarketOpportunitySelectionStepSix, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
@ -135,7 +136,7 @@ async function saveAndGoToStepSix() { if (saving.value || !stepFourPassed.value)
async function saveAndCompleteTraining() { if (saving.value || !stepFivePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepSix({ developmentConclusion: developmentConclusion.value }); dynamicStepAnswers.value = buildDynamicStepAnswers({ step5: buildStepFiveOutcome(), step6: buildStepSixOutcome() }); await saveStudentTrainingAnswer(TASK_KEY, { dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value), currentStep: 6, submitted: true, saveAction: "SUBMIT" }); proxy?.$modal?.msgSuccess("实训成果已保存,恭喜完成本次实训"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写建议开发的产品及具体原因"); } finally { saving.value = false; } }
function downloadFile(content, fileName, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }
function downloadInstructions() { downloadFile("市场机会识别与选品 - 实训操作说明\n\n1. 汇总三类产品的原始数据。\n2. 对正向指标计算 (x-min)/(max-min),对逆向指标计算 (max-x)/(max-min)。\n3. 将归一化得分乘以第 2 步的对应总权重后求和。\n4. 如案例资料提供三段式时间权重,分别计算第 120 天、第 2140 天和第 4160 天的加权得分。\n5. 将产品品类综合得分填写回第 3 步表格,保留两位小数。", "市场机会识别与选品-实训操作说明.txt", "text/plain;charset=utf-8"); }
function downloadTemplate() { const header = ["产品品类", ...indicators.map((item) => item.name), "综合得分"]; const rows = products.map((product) => [product.name, ...indicators.map(() => ""), ""]); const primaryWeightRow = ["一级指标权重(%)", ...indicators.map((item) => primaryWeights.value[item.group] ?? ""), ""]; const dimensionWeightRow = ["维度内权重(%)", ...indicators.map((item) => weights.value[item.id] ?? ""), ""]; const correspondingWeightRow = ["对应总权重(%)", ...indicators.map((item) => correspondingWeight(item)), ""]; downloadFile([header, primaryWeightRow, dimensionWeightRow, correspondingWeightRow, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-自动化模板.csv", "text/csv;charset=utf-8"); }
function downloadTemplate() { const firstProductRow = 5; const lastProductRow = firstProductRow + products.length - 1; const rawStartColumn = 1; const normalizedStartColumn = rawStartColumn + indicators.length; const totalColumn = normalizedStartColumn + indicators.length; const negativeMetricIds = new Set(["online-products", "online-merchants", "return-rate"]); const rows = [["市场机会识别与选品 · Min-Max 加权评分自动化模板"], ["填写原始指标值后,归一得分、对应总权重和综合得分会自动计算。"], [], ["产品品类", ...indicators.map((item) => `${item.name} 原始值`), ...indicators.map((item) => `${item.name} 归一得分`), "综合得分"], ...products.map((product) => [product.name, ...indicators.map(() => ""), ...indicators.map(() => ""), ""]), [], ["权重设置(来自第 2 步)"], ["一级指标权重(%)", ...indicators.map((item) => primaryWeights.value[item.group] ?? ""), ...indicators.map(() => ""), ""], ["维度内权重(%)", ...indicators.map((item) => weights.value[item.id] ?? ""), ...indicators.map(() => ""), ""], ["对应总权重(%)", ...indicators.map(() => ""), ...indicators.map(() => ""), ""]]; const worksheet = XLSX.utils.aoa_to_sheet(rows); indicators.forEach((item, index) => { const rawColumn = XLSX.utils.encode_col(rawStartColumn + index); const normalizedColumn = XLSX.utils.encode_col(normalizedStartColumn + index); const primaryWeightCell = XLSX.utils.encode_cell({ c: rawStartColumn + index, r: 9 }); const dimensionWeightCell = XLSX.utils.encode_cell({ c: rawStartColumn + index, r: 10 }); const correspondingWeightCell = XLSX.utils.encode_cell({ c: rawStartColumn + index, r: 11 }); worksheet[correspondingWeightCell] = { t: "n", f: `${primaryWeightCell}*${dimensionWeightCell}/100` }; products.forEach((product, productIndex) => { const row = firstProductRow + productIndex; const rawCell = `${rawColumn}${row}`; const range = `${rawColumn}$${firstProductRow}:${rawColumn}$${lastProductRow}`; const normalizedCell = `${normalizedColumn}${row}`; worksheet[normalizedCell] = { t: "n", f: negativeMetricIds.has(item.id) ? `IFERROR((MAX(${range})-${rawCell})/(MAX(${range})-MIN(${range})),0)` : `IFERROR((${rawCell}-MIN(${range}))/(MAX(${range})-MIN(${range})),0)` }; }); }); products.forEach((product, productIndex) => { const row = firstProductRow + productIndex; const scoreCell = `${XLSX.utils.encode_col(totalColumn)}${row}`; const normalizedStart = XLSX.utils.encode_col(normalizedStartColumn); const normalizedEnd = XLSX.utils.encode_col(normalizedStartColumn + indicators.length - 1); const correspondingStart = XLSX.utils.encode_col(rawStartColumn); const correspondingEnd = XLSX.utils.encode_col(rawStartColumn + indicators.length - 1); worksheet[scoreCell] = { t: "n", f: `SUMPRODUCT(${normalizedStart}${row}:${normalizedEnd}${row},$${correspondingStart}$12:$${correspondingEnd}$12)/100` }; }); worksheet["!cols"] = [{ wch: 18 }, ...indicators.map(() => ({ wch: 18 })), ...indicators.map(() => ({ wch: 18 })), { wch: 14 }]; const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "综合得分计算"); XLSX.writeFile(workbook, "市场机会识别与选品-自动化模板.xlsx"); }
function downloadVisualizationInstructions() { downloadFile("市场机会识别与选品 - 数据可视化操作说明\n\n1. 按智能健身镜、智能卫浴镜和智能美妆镜汇总产品综合得分。\n2. 分别计算各品类的平均分、最高分和最低分。\n3. 使用柱状图、折线图或组合图完成可视化,并清晰标注品类和得分。\n4. 导出图表为 PNG、JPG 或 WEBP 格式后上传。", "市场机会识别与选品-数据可视化操作说明.txt", "text/plain;charset=utf-8"); }
function downloadVisualizationTemplate() { const header = ["产品品类", "平均得分", "最高分", "最低分"]; const rows = products.map((product) => [product.name, "", "", ""]); downloadFile([header, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-数据可视化模板.csv", "text/csv;charset=utf-8"); }
function exportOutcome() { const blob = new Blob([JSON.stringify({ taskName: taskTitle.value, step1: buildStepOneOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), step5: buildStepFiveOutcome(), step6: buildStepSixOutcome(), currentStep: currentStep.value }, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${taskTitle.value}-实训结果.json`; link.click(); URL.revokeObjectURL(url); }

@ -0,0 +1,28 @@
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 expected = ["smart-fitness-mirror", "smart-bathroom-mirror", "smart-beauty-mirror"];
const productBlock = source.match(/const products = \[([^;]+)\];/)?.[1] || "";
const actual = [...productBlock.matchAll(/id: "([^"]+)"/g)].map((match) => match[1]);
assert.deepEqual(actual, expected);
assert.match(source, /<table class="score-table">[\s\S]*?综合得分[\s\S]*?v-model\.number="scores\[product\.id\]"[\s\S]*?min="0" max="100" step="0\.01"/);
});
test("第三步下载真实 Excel 自动化模板,并保留 Min-Max 与三段式权重说明", () => {
assert.match(source, /import \* as XLSX from "xlsx"/);
assert.match(source, /XLSX\.utils\.book_new\(\)/);
assert.match(source, /XLSX\.utils\.book_append_sheet\(workbook, worksheet, "综合得分计算"\)/);
assert.match(source, /XLSX\.writeFile\(workbook, "市场机会识别与选品-自动化模板\.xlsx"\)/);
assert.match(source, /primaryWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 9 \}\)/);
assert.match(source, /dimensionWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 10 \}\)/);
assert.match(source, /correspondingWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 11 \}\)/);
assert.match(source, /\$\$\{correspondingStart\}\$12:\$\$\{correspondingEnd\}\$12/);
assert.match(source, /Min-Max 归一法/);
assert.match(source, /第 120 天、第 2140 天、第 4160 天/);
});
Loading…
Cancel
Save