From f23f064754233096c04cf401647aa1fcc9ae52e7 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 18 Aug 2026 11:14:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=AE=9E=E8=AE=AD?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E6=A8=A1=E5=9D=97=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/studentTrainingAnswer.js | 1044 +++++++- src/views/assessment/diagnosis.vue | 113 +- src/views/assessment/optimization.vue | 385 ++- src/views/channel/index.vue | 703 +++++- src/views/channel/supplier.vue | 1615 ++++--------- .../components/TrainingMaterialButton.vue | 73 +- src/views/product/after-sales-service.vue | 510 ++-- src/views/product/hardware-cost.vue | 761 +++++- src/views/product/price.vue | 2109 +++++++++++------ src/views/product/requirements-doc.vue | 526 +++- src/views/product/structure.vue | 698 +++--- src/views/test/index.vue | 930 ++------ src/views/test/promotion.vue | 1029 ++------ 13 files changed, 6278 insertions(+), 4218 deletions(-) diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index 31d50a1..4a96444 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -30,7 +30,7 @@ export function getStudentTrainingAnswer(taskKey, params) { export function saveStudentTrainingAnswer(taskKey, data) { if (isStudentDemo()) { - const answer = { ...data, teachingClassId: getStudentDemoSession()?.userInfo?.demoTeachingClassId }; + const answer = { ...(demoResult(taskKey) || {}), ...data, teachingClassId: getStudentDemoSession()?.userInfo?.demoTeachingClassId }; sessionStorage.setItem(getDemoAnswerKey(taskKey), JSON.stringify(answer)); return Promise.resolve({ code: 200, data: answer }); } @@ -95,6 +95,536 @@ export function checkNewProductSurveyStepOne(items) { }); } +export function validateReleaseChannelEvaluationStepOne(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + const normalizedRows = rows.map((row) => ({ + dimension: String(row?.dimension || "").trim(), + description: String(row?.description || "").trim(), + weight: Number(row?.weight), + })); + const totalWeight = Number(normalizedRows.reduce((sum, row) => sum + row.weight, 0).toFixed(6)); + if (!normalizedRows.length || normalizedRows.some((row) => !row.dimension || !row.description + || !Number.isFinite(row.weight) || row.weight < 0) || totalWeight !== 100) { + return Promise.reject(new Error("请填写每行评估维度、维度说明和非负权重,且权重之和必须等于100%")); + } + return Promise.resolve({ code: 200, data: { valid: true, message: "权重校验通过", totalWeight } }); + } + return request({ + url: "/api/student-training-answers/release-channel-evaluation/step-1/validate", + method: "post", + data: { rows }, + headers: { repeatSubmit: false }, + }); +} + +export function validateReleaseChannelEvaluationStepTwo(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + const normalizedRows = rows.map((row) => ({ + channel: String(row?.channel || "").trim(), + dailySearchVolume: Number(row?.dailySearchVolume), + supplyDemandRatio: Number(row?.supplyDemandRatio), + averageClickRate: Number(row?.averageClickRate), + averageCpc: Number(row?.averageCpc), + averageConversionRate: Number(row?.averageConversionRate), + existingVisitorCount: Number(row?.existingVisitorCount), + matchingScore: Number(row?.matchingScore), + })); + const valid = normalizedRows.length > 0 && normalizedRows.every((row) => row.channel + && [row.dailySearchVolume, row.supplyDemandRatio, row.averageCpc, row.existingVisitorCount] + .every((value) => Number.isFinite(value) && value >= 0) + && [row.averageClickRate, row.averageConversionRate].every((value) => Number.isFinite(value) && value >= 0 && value <= 100) + && Number.isInteger(row.matchingScore) && row.matchingScore >= 1 && row.matchingScore <= 5); + if (!valid) return Promise.reject(new Error("请完整填写渠道数据;点击率和转化率为0-100%,匹配度为1-5分")); + return Promise.resolve({ code: 200, data: { valid: true, message: "渠道数据校验通过", rowCount: normalizedRows.length } }); + } + return request({ + url: "/api/student-training-answers/release-channel-evaluation/step-2/validate", + method: "post", + data: { rows }, + headers: { repeatSubmit: false }, + }); +} + +export function validateReleaseChannelEvaluationStepThree(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + try { + const saved = demoResult("release-channel-evaluation") || {}; + const stepOne = typeof saved.step1Answer === "string" ? JSON.parse(saved.step1Answer || "{}") : saved.step1Answer || {}; + const stepTwo = typeof saved.step2Answer === "string" ? JSON.parse(saved.step2Answer || "{}") : saved.step2Answer || {}; + const sourceRows = Array.isArray(stepTwo.rows) ? stepTwo.rows : []; + const weights = Object.fromEntries((Array.isArray(stepOne.rows) ? stepOne.rows : []).map((row) => [row?.dimension, Number(row?.weight)])); + const metrics = [ + ["市场规模潜力", "dailySearchVolume", false], ["竞争激烈程度", "supplyDemandRatio", true], + ["流量点击效率", "averageClickRate", false], ["流量获取成本", "averageCpc", true], + ["流量转化效率", "averageConversionRate", false], ["现有用户基础", "existingVisitorCount", false], ["匹配度", "matchingScore", false], + ]; + if (!sourceRows.length || rows.length !== sourceRows.length) throw new Error("请先完成第二步全部渠道数据"); + const submitted = new Map(rows.map((row) => [String(row?.channel || "").trim(), row])); + for (const source of sourceRows) { + const channel = String(source?.channel || "").trim(); + const row = submitted.get(channel); + if (!channel || !row) throw new Error("第三步渠道必须与第二步保持一致"); + const dimensions = new Map((Array.isArray(row.dimensions) ? row.dimensions : []).map((item) => [item?.dimension, item])); + let expectedTotal = 0; + metrics.forEach(([dimension, field, reverse]) => { + const weight = Number(weights[dimension]); + const values = sourceRows.map((item) => Number(item?.[field])); + const value = Number(source?.[field]); + const min = Math.min(...values); + const max = Math.max(...values); + const expectedScore = max === min ? 10 : Number((((reverse ? max - value : value - min) * 10) / (max - min)).toFixed(1)); + const supplied = dimensions.get(dimension); + if (!supplied || Number(supplied.weight) !== weight || Number(supplied.score) !== expectedScore) throw new Error(`“${channel}”的“${dimension}”得分或权重不正确`); + expectedTotal += expectedScore * weight / 100; + }); + if (Number(row.weightedScore) !== Number(expectedTotal.toFixed(2))) throw new Error(`“${channel}”的加权得分不正确`); + } + return Promise.resolve({ code: 200, data: { valid: true, message: "归一化得分与加权得分校验通过", rowCount: rows.length } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/release-channel-evaluation/step-3/validate", + method: "post", + data: { rows }, + headers: { repeatSubmit: false }, + }); +} + +export function validateReleaseChannelEvaluationStepFour(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + try { + const analysis = String(payload?.coreChannelAnalysis || "").trim(); + const saved = demoResult("release-channel-evaluation") || {}; + const stepThree = typeof saved.step3Answer === "string" ? JSON.parse(saved.step3Answer || "{}") : saved.step3Answer || {}; + const expectedRows = Array.isArray(stepThree.rows) ? stepThree.rows : []; + if (!analysis) throw new Error("请分析核心渠道优势并填写运营建议"); + if (!expectedRows.length || rows.length !== expectedRows.length) throw new Error("渠道数量必须与第三步一致"); + const expected = new Map(expectedRows.map((row) => [String(row?.channel || "").trim(), Number(row?.weightedScore)])); + const channels = new Set(); + let previousScore = null; + rows.forEach((row, index) => { + const channel = String(row?.channel || "").trim(); + const score = Number(row?.weightedScore); + if (!channel || channels.has(channel) || !expected.has(channel)) throw new Error(`第${index + 1}行渠道必须与第三步一致且不能重复`); + if (!Number.isFinite(score) || score !== expected.get(channel)) throw new Error(`“${channel}”的加权综合得分必须与第三步一致`); + if (previousScore !== null && previousScore < score) throw new Error("加权综合得分必须按从高到低顺序填写"); + channels.add(channel); + previousScore = score; + }); + return Promise.resolve({ code: 200, data: { valid: true, message: "渠道排名与核心渠道校验通过", coreChannels: rows.slice(0, 2).map((row) => row.channel) } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/release-channel-evaluation/step-4/validate", + method: "post", + data: { rows, coreChannelAnalysis: payload?.coreChannelAnalysis }, + headers: { repeatSubmit: false }, + }); +} + +export function validateProductDiagnosisIterationStepOne(payload) { + const value = payload || {}; + const conversionRates = Array.isArray(value.conversionRates) ? value.conversionRates : []; + const anomalyRows = Array.isArray(value.anomalyRows) ? value.anomalyRows : []; + if (isStudentDemo()) { + try { + const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"]; + const channels = ["天猫", "京东", "抖音", "拼多多"]; + if (conversionRates.length !== nodes.length || new Set(conversionRates.map((row) => String(row?.linkNode || "").trim())).size !== nodes.length) { + throw new Error("请完整填写 5 个链路节点的转化率"); + } + const rates = new Map(); + 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); + if (values.some((item) => !Number.isFinite(item) || item < 0 || item > 100)) throw new Error("请填写 0 至 100 之间的转化率"); + rates.set(node, { ...row, values }); + }); + 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 node = String(row?.linkNode || "").trim(); + const source = rates.get(node); + if (!channels.includes(channel)) throw new Error(`第 ${index + 1} 行渠道必须来源于第一个表格`); + if (!source) throw new Error(`第 ${index + 1} 行链路节点必须来源于第一个表格`); + const sourceRate = source[{ 天猫: "tmall", 京东: "jd", 抖音: "douyin", 拼多多: "pinduoduo" }[channel]]; + if (Number(row?.storeConversionRate) !== Number(sourceRate) || Number(row?.industryAverageConversionRate) !== Number(source.industryAverage)) { + throw new Error(`第 ${index + 1} 行的店铺和行业转化率必须与第一个表格对应数据一致`); + } + if (!String(row?.anomalyAnalysis || "").trim()) throw new Error(`请填写第 ${index + 1} 行的异常分析与判定`); + }); + return Promise.resolve({ code: 200, data: { valid: true, message: "转化率、漏斗图和异常节点分析校验通过" } }); + } catch (error) { return Promise.reject(error); } + } + return request({ + url: "/api/student-training-answers/product-diagnosis-iteration/step-1/validate", + method: "post", + data: { conversionRates, funnelChartUrl: value.funnelChartUrl, anomalyRows }, + headers: { repeatSubmit: false }, + }); +} + +export async function analyzeProductDiagnosisIterationStepTwo(file) { + if (isStudentDemo()) { + const XLSX = await import("xlsx"); + const buffer = await file.arrayBuffer(); + const name = String(file?.name || "评论数据"); + let comments = []; + if (/\.xlsx?$/i.test(name)) { + const workbook = XLSX.read(buffer, { type: "array" }); + const rows = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { header: 1, defval: "" }); + comments = rows.slice(1).map((row) => row.map((item) => String(item || "").trim()).filter((item) => /[\u4e00-\u9fa5A-Za-z]/.test(item)).sort((a, b) => b.length - a.length)[0] || "").filter(Boolean); + } else comments = new TextDecoder("utf-8").decode(buffer).split(/\r?\n/).slice(1).map((line) => line.split(/[,,\t]/).map((item) => item.trim()).sort((a, b) => b.length - a.length)[0] || "").filter(Boolean); + const positiveWords = ["很好", "喜欢", "满意", "推荐", "好用", "值得", "清晰", "漂亮", "方便", "优秀", "赞"]; + const negativeWords = ["太慢", "差", "坏", "问题", "失望", "退货", "卡顿", "慢", "漏水", "故障", "不行", "不值", "异味"]; + const issueWords = ["质量", "物流", "故障", "外观", "价格", "包装", "发货", "售后", "退货", "卡顿", "漏水", "安装", "尺寸", "噪音", "异味", "续航", "慢"]; + const count = (text, words) => words.filter((word) => text.includes(word)).length; + const frequencies = new Map(); let positiveCount = 0; let neutralCount = 0; let negativeCount = 0; + comments.forEach((comment) => { const positive = count(comment, positiveWords); const negative = count(comment, negativeWords); if (positive > negative) positiveCount += 1; else if (negative > positive) negativeCount += 1; else neutralCount += 1; issueWords.forEach((word) => { if (comment.includes(word)) frequencies.set(word, (frequencies.get(word) || 0) + 1); }); }); + const wordCloud = [...frequencies.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh-CN")).slice(0, 30).map(([name, value]) => ({ name, value })); + if (!comments.length) return Promise.reject(new Error("未从文件中读取到有效评论内容")); + return { code: 200, data: { sourceFileName: name, totalComments: comments.length, positiveCount, neutralCount, negativeCount, topIssues: wordCloud.slice(0, 3).map((item) => item.name), wordCloud, previewComments: comments.slice(0, 5) } }; + } + const data = new FormData(); data.append("file", file); + return request({ url: "/api/student-training-answers/product-diagnosis-iteration/step-2/analyze", method: "post", data, headers: { "Content-Type": "multipart/form-data", repeatSubmit: false } }); +} + +export function validateProductDiagnosisIterationStepTwo(payload) { + const value = payload || {}; const sentiments = Array.isArray(value.sentiments) ? value.sentiments : []; + if (isStudentDemo()) { + try { + const topIssues = Array.isArray(value.topIssues) ? value.topIssues.map((item) => String(item || "").trim()) : []; + if (!String(value.sourceFileName || "").trim()) throw new Error("请先导入评论数据并生成词云"); + if (topIssues.length !== 3 || topIssues.some((item) => !item) || new Set(topIssues).size !== 3) throw new Error("请填写词云中出现频次最高的 3 个不重复问题"); + if (!String(value.rootCauseAnalysis || "").trim()) throw new Error("请结合词云和情感分析填写核心问题根本原因"); + if (sentiments.length !== 3) throw new Error("请填写正面、中性、负面三类情感数据"); + const total = sentiments.reduce((sum, item) => sum + Number(item?.count), 0); + if (!Number.isFinite(total) || total <= 0) throw new Error("情感评论数量合计必须大于 0"); + sentiments.forEach((item) => { const expected = Number((Number(item?.count) * 100 / total).toFixed(2)); if (!Number.isFinite(Number(item?.count)) || Number(item?.count) < 0 || Number(item?.percentage) !== expected) throw new Error(`${item?.type || "情感"}评论占比计算不正确`); }); + return Promise.resolve({ code: 200, data: { valid: true, message: "情感评论占比计算校验通过" } }); + } catch (error) { return Promise.reject(error); } + } + return request({ url: "/api/student-training-answers/product-diagnosis-iteration/step-2/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validateProductDiagnosisIterationStepThree(payload) { + const value = payload || {}; const rows = Array.isArray(value.rows) ? value.rows : []; + if (isStudentDemo()) { + try { + const metrics = ["点击-加购转化率", "加购-下单转化率", "下单-好评转化率", "详情页跳出率", "退货率"]; + if (!String(value.testPlan || "").trim()) throw new Error("请填写 AB 测试方案"); + if (!String(value.comparisonChartUrl || "").trim()) throw new Error("请上传 AB 测试结果对比图"); + if (!String(value.iterationRecommendation || "").trim()) throw new Error("请填写诊断结论与迭代优化建议"); + if (rows.length !== metrics.length || new Set(rows.map((row) => String(row?.metric || "").trim())).size !== metrics.length) throw new Error("请完整填写 5 项 AB 测试指标"); + rows.forEach((row) => { const control = Number(row?.controlValue); const experiment = Number(row?.experimentValue); if (!metrics.includes(String(row?.metric || "").trim()) || !Number.isFinite(control) || !Number.isFinite(experiment) || control < 0 || control > 100 || experiment < 0 || experiment > 100 || !String(row?.analysis || "").trim()) throw new Error("请完整填写五项指标的对照组、实验组数据和判定分析"); }); + return Promise.resolve({ code: 200, data: { valid: true, message: "AB 测试方案、数据与对比图校验通过" } }); + } catch (error) { return Promise.reject(error); } + } + return request({ url: "/api/student-training-answers/product-diagnosis-iteration/step-3/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validatePromotionEffectOptimizationStepOne(payload) { + const value = payload || {}; + const rows = Array.isArray(value.rows) ? value.rows : []; + if (isStudentDemo()) { + try { + const totalBudget = Number(value.totalBudget); + if (!Number.isFinite(totalBudget) || totalBudget <= 0) throw new Error("请填写大于 0 的新产品投放试错预算"); + if (rows.length < 2 || rows.length > 5) throw new Error("请填写 2 至 5 个投放渠道"); + const saved = demoResult("release-channel-evaluation") || {}; + const stepTwo = typeof saved.step2Answer === "string" ? JSON.parse(saved.step2Answer || "{}") : saved.step2Answer || {}; + const availableChannels = new Set((Array.isArray(stepTwo.rows) ? stepTwo.rows : []) + .map((row) => String(row?.channel || "").trim()).filter(Boolean)); + if (!availableChannels.size) throw new Error("请先完成产品发布渠道评估中的渠道数据填写"); + const selected = new Set(); + let totalPercentage = 0; + rows.forEach((row, index) => { + const channel = String(row?.channel || "").trim(); + const positioning = String(row?.channelPositioning || "").trim(); + const percentage = Number(row?.budgetPercentage); + const amount = Number(row?.budgetAmount); + const phases = [row?.coldStartBudget, row?.growthBudget, row?.stableBudget].map(Number); + if (!availableChannels.has(channel) || selected.has(channel)) throw new Error(`第 ${index + 1} 行渠道必须来自产品发布渠道评估,且不能重复`); + if (!positioning) throw new Error(`请选择第 ${index + 1} 行的渠道定位`); + if (!Number.isFinite(percentage) || percentage < 0) throw new Error(`请填写第 ${index + 1} 行的非负预算占比`); + const expectedAmount = Number((totalBudget * percentage / 100).toFixed(2)); + if (!Number.isFinite(amount) || Number(amount.toFixed(2)) !== expectedAmount) throw new Error(`第 ${index + 1} 行预算金额应由试错预算和预算占比自动计算`); + if (phases.some((item) => !Number.isFinite(item) || item < 0) || Number(phases.reduce((sum, item) => sum + item, 0).toFixed(2)) !== expectedAmount) { + throw new Error(`第 ${index + 1} 行三个阶段投放预算之和必须等于预算金额`); + } + selected.add(channel); + totalPercentage += percentage; + }); + if (Number(totalPercentage.toFixed(6)) !== 100) throw new Error("各渠道预算占比之和必须等于 100%"); + return Promise.resolve({ code: 200, data: { valid: true, message: "投放预算分配校验通过", totalBudget: Number(totalBudget.toFixed(2)), availableChannels: [...availableChannels] } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/promotion-effect-optimization/step-1/validate", + method: "post", + data: { totalBudget: value.totalBudget, rows }, + headers: { repeatSubmit: false }, + }); +} + +export function validatePromotionEffectOptimizationStepTwo(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + try { + const saved = demoResult("promotion-effect-optimization") || {}; + const stepOne = typeof saved.step1Answer === "string" ? JSON.parse(saved.step1Answer || "{}") : saved.step1Answer || {}; + const channels = [...new Set((Array.isArray(stepOne.rows) ? stepOne.rows : []) + .map((row) => String(row?.channel || "").trim()).filter(Boolean))]; + const phases = ["冷启动期", "增长期", "稳定期"]; + if (channels.length < 2 || channels.length > 5) throw new Error("请先完成并保存第一步投放预算分配"); + if (rows.length !== channels.length * phases.length) throw new Error("请为每个渠道的冷启动期、增长期和稳定期分别填写两项投放策略"); + const cells = new Set(); + rows.forEach((row) => { + const phase = String(row?.phase || "").trim(); + const channel = String(row?.channel || "").trim(); + const strategies = Array.isArray(row?.strategies) ? row.strategies.map((item) => String(item || "").trim()) : []; + if (!phases.includes(phase) || !channels.includes(channel)) throw new Error("投放策略的阶段和渠道必须与第一步保持一致"); + const cell = `${phase}\u0000${channel}`; + if (cells.has(cell)) throw new Error("每个阶段和渠道只能填写一组投放策略"); + if (strategies.length !== 2 || strategies.some((item) => !item) || new Set(strategies).size !== 2) { + throw new Error(`“${phase} - ${channel}”必须填写两项不重复的投放策略`); + } + cells.add(cell); + }); + return Promise.resolve({ code: 200, data: { valid: true, message: "各阶段投放策略校验通过", channelCount: channels.length, cellCount: cells.size } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/promotion-effect-optimization/step-2/validate", + method: "post", + data: { rows }, + headers: { repeatSubmit: false }, + }); +} + +export function validatePromotionEffectOptimizationStepThree(payload) { + const value = payload || {}; + const rows = Array.isArray(value.rows) ? value.rows : []; + if (isStudentDemo()) { + try { + const grossMargin = Number(value.grossMargin); + const breakEvenRoi = Number(value.breakEvenRoi); + if (!Number.isFinite(grossMargin) || grossMargin <= 0 || grossMargin > 100) throw new Error("请填写大于 0 且不超过 100% 的产品毛利率"); + if (breakEvenRoi !== Number((100 / grossMargin).toFixed(2))) throw new Error("保本 ROI 应等于 1 ÷ 毛利率,结果保留两位小数"); + const saved = demoResult("promotion-effect-optimization") || {}; + const stepOne = typeof saved.step1Answer === "string" ? JSON.parse(saved.step1Answer || "{}") : saved.step1Answer || {}; + const channels = [...new Set((Array.isArray(stepOne.rows) ? stepOne.rows : []).map((row) => String(row?.channel || "").trim()).filter(Boolean))]; + if (rows.length !== channels.length + 1) throw new Error("请填写所有渠道数据,并在最后一行填写合计"); + const channelRows = rows.slice(0, -1); + const sum = { impressions: 0, clicks: 0, turnover: 0, promotionCost: 0 }; + const validateRow = (row, label) => { + const values = ["impressions", "clicks", "turnover", "promotionCost", "clickRate", "actualRoi"].reduce((all, key) => ({ ...all, [key]: Number(row?.[key]) }), {}); + if (!Number.isFinite(values.impressions) || values.impressions <= 0 || !Number.isFinite(values.clicks) || values.clicks < 0 || !Number.isFinite(values.turnover) || values.turnover < 0 || !Number.isFinite(values.promotionCost) || values.promotionCost <= 0 || !Number.isFinite(values.clickRate) || !Number.isFinite(values.actualRoi)) throw new Error(`请完整填写${label}的投放数据`); + if (values.clickRate !== Number((values.clicks / values.impressions * 100).toFixed(2))) throw new Error(`${label}的点击率应等于点击量 ÷ 曝光量 × 100%`); + if (values.actualRoi !== Number((values.turnover / values.promotionCost).toFixed(2))) throw new Error(`${label}的实际 ROI 应等于成交额 ÷ 推广花费`); + return values; + }; + const seen = new Set(); + channelRows.forEach((row) => { const channel = String(row?.channel || "").trim(); if (!channels.includes(channel) || seen.has(channel)) throw new Error("投放数据渠道必须与第一步完全一致"); seen.add(channel); const values = validateRow(row, `“${channel}”`); ["impressions", "clicks", "turnover", "promotionCost"].forEach((key) => { sum[key] += values[key]; }); }); + if (seen.size !== channels.length) throw new Error("投放数据渠道必须与第一步完全一致"); + const total = rows[rows.length - 1]; + if (String(total?.channel || "").trim() !== "合计") throw new Error("合计必须固定填写在最后一行"); + const totalValues = validateRow(total, "合计"); + if (["impressions", "clicks", "turnover", "promotionCost"].some((key) => totalValues[key] !== Number(sum[key].toFixed(2)))) throw new Error("合计行的曝光量、点击量、成交额和推广花费必须等于所有渠道数据之和"); + return Promise.resolve({ code: 200, data: { valid: true, message: "保本 ROI 与投放数据校验通过", breakEvenRoi } }); + } catch (error) { return Promise.reject(error); } + } + return request({ url: "/api/student-training-answers/promotion-effect-optimization/step-3/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validatePromotionEffectOptimizationStepFour(payload) { + const value = payload || {}; + const rows = Array.isArray(value.rows) ? value.rows : []; + if (isStudentDemo()) { + try { + const decision = String(value.optimizationDecision || "").trim(); + if (!decision) throw new Error("请填写整体优化决策"); + const saved = demoResult("promotion-effect-optimization") || {}; + const stepThree = typeof saved.step3Answer === "string" ? JSON.parse(saved.step3Answer || "{}") : saved.step3Answer || {}; + const breakEvenRoi = Number(stepThree.breakEvenRoi); + const sourceRows = Array.isArray(stepThree.rows) ? stepThree.rows : []; + const channelRois = new Map(sourceRows.filter((row) => row?.channel !== "合计").map((row) => [String(row?.channel || "").trim(), Number(row?.actualRoi)])); + if (!Number.isFinite(breakEvenRoi) || !channelRois.size || rows.length !== channelRois.size) throw new Error("请先完成并保存第三步保本 ROI 与投放数据核算"); + const seen = new Set(); + rows.forEach((row) => { + const channel = String(row?.channel || "").trim(); + const expectedRoi = channelRois.get(channel); + const actualRoi = Number(row?.roi); + const shouldAchieve = expectedRoi >= breakEvenRoi; + if (!Number.isFinite(expectedRoi) || seen.has(channel) || actualRoi !== expectedRoi) throw new Error("评估渠道与 ROI 必须和第三步保持一致"); + if (row?.achievement !== (shouldAchieve ? "达标" : "不达标")) throw new Error(`“${channel}”的是否达标判断不正确`); + if (!shouldAchieve && (!String(row?.problemDiagnosis || "").trim() || !String(row?.optimizationAction || "").trim())) throw new Error(`“${channel}”未达标,必须填写问题诊断和优化动作`); + seen.add(channel); + }); + const total = sourceRows.find((row) => row?.channel === "合计"); + const overallRoi = Number(total?.actualRoi); + return Promise.resolve({ code: 200, data: { valid: true, message: "投放效果评估与优化决策校验通过", overallRoi, breakEvenRoi, overallAchievement: overallRoi >= breakEvenRoi ? "达标" : "不达标" } }); + } catch (error) { return Promise.reject(error); } + } + return request({ url: "/api/student-training-answers/promotion-effect-optimization/step-4/validate", method: "post", data: { rows, optimizationDecision: value.optimizationDecision }, headers: { repeatSubmit: false } }); +} + +export function validatePurchaseDemandForecastStepOne(payload) { + const items = Array.isArray(payload?.items) ? payload.items : []; + if (isStudentDemo()) { + try { + const expectedTimes = [13, 14, 15]; + if (items.length !== expectedTimes.length) throw new Error("请完整填写未来3个月的市场需求量"); + const normalizedItems = expectedTimes.map((time, index) => { + const item = items.find((entry) => Number(entry?.time) === time); + const demand = Number(item?.demand); + if (!item || !Number.isInteger(demand) || demand < 0) { + throw new Error(`请填写预测第${index + 1}个月的非负整数需求量`); + } + return { time, demand }; + }); + return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过", items: normalizedItems } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/purchase-demand-forecast/step-1/validate", + method: "post", + data: { items }, + headers: { repeatSubmit: false }, + }); +} + +export function validateCategoryOptimizationStepOne(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + const tolerance = 0.01; + if (!rows.length) return Promise.reject(new Error("请至少填写一行品类数据")); + if (rows.length > 30) return Promise.reject(new Error("品类数据最多填写30行")); + const numbers = rows.map((row, index) => ({ + index, + categoryName: String(row?.categoryName || "").trim(), + revenue: Number(row?.revenue), + revenueShare: Number(row?.revenueShare), + cumulativeShare: Number(row?.cumulativeShare), + })); + const incomplete = numbers.find((row) => !row.categoryName || [row.revenue, row.revenueShare, row.cumulativeShare].some((value) => !Number.isFinite(value) || value < 0)); + if (incomplete) return Promise.reject(new Error(`第${incomplete.index + 1}行:请完整填写品类名称、近12个月营收、营收占比和累计占比`)); + const totalRevenue = numbers.reduce((sum, row) => sum + row.revenue, 0); + if (totalRevenue <= 0) return Promise.reject(new Error("近12个月营收总和必须大于0")); + let accumulated = 0; + for (const row of numbers) { + const previous = numbers[row.index - 1]; + if (previous && previous.revenue < row.revenue) return Promise.reject(new Error(`第${row.index + 1}行:近12个月营收需按从高到低排序`)); + if (Math.abs(row.revenueShare - (row.revenue / totalRevenue) * 100) > tolerance) return Promise.reject(new Error(`第${row.index + 1}行:营收占比与近12个月营收不一致`)); + accumulated += row.revenueShare; + if (Math.abs(row.cumulativeShare - accumulated) > tolerance) return Promise.reject(new Error(`第${row.index + 1}行:累计占比应等于从第一行到当前行的营收占比之和`)); + } + return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } }); + } + return request({ url: "/api/student-training-answers/category-optimization/step-1/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } }); +} + +export function validateCategoryOptimizationStepTwo(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + if (!rows.length) return Promise.reject(new Error("请先完成第一步品类营收数据填写")); + for (const [index, row] of rows.entries()) { + const cumulativeShare = Number(row?.cumulativeShare); + const expected = cumulativeShare <= 70 ? "A" : cumulativeShare <= 90 ? "B" : "C"; + if (!["A", "B", "C"].includes(row?.abcClassification)) return Promise.reject(new Error(`第${index + 1}行:请选择ABC分层判定`)); + if (row.abcClassification !== expected) return Promise.reject(new Error(`第${index + 1}行:累计营收占比为${row.cumulativeShare}% ,应判定为${expected}类`)); + } + return Promise.resolve({ code: 200, data: { valid: true, message: "ABC分层校验通过" } }); + } + return request({ url: "/api/student-training-answers/category-optimization/step-2/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } }); +} + +export function validateCategoryOptimizationStepThree(payload) { + const imageUrl = String(payload?.imageUrl || "").trim(); + if (isStudentDemo()) { + if (!imageUrl) return Promise.reject(new Error("请上传帕累托图")); + return Promise.resolve({ code: 200, data: { valid: true, message: "帕累托图上传校验通过" } }); + } + return request({ url: "/api/student-training-answers/category-optimization/step-3/validate", method: "post", data: { imageUrl }, headers: { repeatSubmit: false } }); +} + +export function validateCategoryOptimizationStepFour(payload) { + const value = payload || {}; + const fields = ["productName", "matrixQuadrant", "matrixReason", "necessityAnalysis", "recommendedClassification", "trafficSupportMeasures"]; + if (isStudentDemo()) { + if (fields.some((field) => !String(value[field] || "").trim())) return Promise.reject(new Error("请完整填写新品矩阵定位、调整必要性与流量扶持措施")); + const product = (Array.isArray(value.availableRows) ? value.availableRows : []).find((row) => row?.categoryName === value.productName); + if (!product) return Promise.reject(new Error("新品名称必须为第一步品类列表中的一项")); + if (value.recommendedClassification === product.abcClassification) return Promise.reject(new Error("建议调整分层不能与当前分层相同")); + return Promise.resolve({ code: 200, data: { valid: true, message: "新品分层优化校验通过", currentClassification: product.abcClassification } }); + } + const data = Object.fromEntries(fields.map((field) => [field, value[field]])); + return request({ url: "/api/student-training-answers/category-optimization/step-4/validate", method: "post", data, headers: { repeatSubmit: false } }); +} + +export function validateCategoryOptimizationStepFive(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + const layers = new Set(rows.map((row) => row?.layer)); + const complete = rows.length === 4 && ["A", "B", "C", "NEW"].every((layer) => layers.has(layer)) && rows.every((row) => String(row?.operationStrategy || "").trim() && String(row?.inventoryStrategy || "").trim() && String(row?.resourcePriority || "").trim()); + if (!complete) return Promise.reject(new Error("请完整填写A、B、C类及新品四行的运营策略、库存策略和资源配置优先级")); + return Promise.resolve({ code: 200, data: { valid: true, message: "差异化运营策略校验通过" } }); + } + return request({ url: "/api/student-training-answers/category-optimization/step-5/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } }); +} + +const requirementsDocumentStepOneAnswers = { + "document-info": "document-governance", + "product-overview": "product-boundary", + "user-needs-scenarios": "user-context", + "core-functional-requirements": "functional-scope", + "non-functional-requirements": "quality-attributes", + "hardware-spec-design": "hardware-constraints", + "testing-requirements": "acceptance-criteria", + "project-planning": "resource-plan", + "change-management": "change-control", +}; + +export function checkRequirementsDocumentStepOne(payload) { + const assignments = payload?.assignments && typeof payload.assignments === "object" ? payload.assignments : {}; + if (isStudentDemo()) { + const expectedModules = Object.keys(requirementsDocumentStepOneAnswers); + const wrongModuleIds = expectedModules.filter((moduleId) => assignments[moduleId] !== requirementsDocumentStepOneAnswers[moduleId]); + const allCorrect = wrongModuleIds.length === 0 && Object.keys(assignments).length === expectedModules.length; + return Promise.resolve({ + code: 200, + data: { + allCorrect, + correctCount: expectedModules.length - wrongModuleIds.length, + totalCount: expectedModules.length, + message: allCorrect ? "匹配正确,已解锁第 2 步。" : "当前匹配尚未全部正确,请继续调整。", + }, + }); + } + return request({ + url: "/api/student-training-answers/requirements-doc/step-1/check", + method: "post", + data: { assignments }, + headers: { repeatSubmit: false }, + }); +} + export function checkMarketOpportunitySelectionStepOne(items) { const expected = { "search-count": "市场规模", "demand-supply-ratio": "竞争强度", "transaction-amount": "市场规模", "transaction-growth": "市场潜力", @@ -172,6 +702,165 @@ export function checkProductDevelopmentProcessStepOne(items) { }); } +export function checkProductArchitectureDesignStepOne(layerIds) { + const expected = ["hardware-physical", "driver-sensing", "algorithm-processing", "application-function"]; + const items = Array.isArray(layerIds) ? layerIds : []; + if (isStudentDemo()) { + const correctPositionCount = expected.reduce((total, id, index) => total + (items[index] === id ? 1 : 0), 0); + return Promise.resolve({ + code: 200, + data: { + correctPositionCount, + totalCount: expected.length, + allCorrect: items.length === expected.length && correctPositionCount === expected.length, + message: correctPositionCount === expected.length ? "排序正确,已解锁下一步。" : "排序尚未正确,请继续调整。", + }, + }); + } + return request({ + url: "/api/student-training-answers/product-structure/step-1/check", + method: "post", + data: { layerIds: items }, + headers: { repeatSubmit: false }, + }); +} + +export function checkAfterSalesServiceStepOne(items) { + const expected = { + "voice-wake-no-response": "local-hardware-simple", + "voice-control-cannot-switch": "local-hardware-simple", + "app-cannot-find-device": "operation-network", + "network-setup-timeout": "operation-network", + "remote-control-intermittent": "cloud-connection", + "frequent-disconnection": "cloud-connection", + "indicator-no-response": "severe-fault", + "water-damage-no-response": "severe-fault", + }; + const rows = Array.isArray(items) ? items : []; + if (isStudentDemo()) { + const correctCount = rows.filter((item) => expected[item?.faultId] === item?.categoryId).length; + return Promise.resolve({ + code: 200, + data: { + correctCount, + totalCount: Object.keys(expected).length, + allCorrect: rows.length === Object.keys(expected).length && correctCount === Object.keys(expected).length, + message: correctCount === Object.keys(expected).length ? "分类正确,已解锁下一步。" : "分类尚未完全正确,请继续调整。", + }, + }); + } + return request({ + url: "/api/student-training-answers/after-sales-service/step-1/check", + method: "post", + data: { items: rows }, + headers: { repeatSubmit: false }, + }); +} + +export function checkAfterSalesServiceStepTwo(payload) { + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + if (isStudentDemo()) { + const expectedIds = ["operation-network", "cloud-connection", "local-hardware-simple", "severe-fault", "out-of-warranty"]; + const valid = rows.length === expectedIds.length + && rows.every((row, index) => row?.id === expectedIds[index] + && Boolean(String(row?.process || "").trim()) + && Boolean(String(row?.rule || "").trim())); + return Promise.resolve({ + code: 200, + data: { valid, message: valid ? "服务分级与处理方案填写完整。" : "请完成五类故障的处理流程和核心规则。" }, + }); + } + return request({ + url: "/api/student-training-answers/after-sales-service/step-2/validate", + method: "post", + data: { rows }, + headers: { repeatSubmit: false }, + }); +} + +export function checkAfterSalesServiceStepThree(payload) { + const policies = Array.isArray(payload?.policies) ? payload.policies : []; + const summaries = Array.isArray(payload?.summaries) ? payload.summaries : []; + const policyIds = ["return-policy", "warranty-policy", "repair-service", "response-sla", "compensation-plan"]; + const summaryIds = ["warranty-period", "response-commitment", "featured-service"]; + if (isStudentDemo()) { + const valid = policies.length === policyIds.length + && policies.every((row, index) => row?.id === policyIds[index] + && Boolean(String(row?.content || "").trim()) + && Boolean(String(row?.condition || "").trim())) + && summaries.length === summaryIds.length + && summaries.every((row, index) => row?.id === summaryIds[index] && Boolean(String(row?.content || "").trim())); + return Promise.resolve({ + code: 200, + data: { valid, message: valid ? "售后政策框架与详情页摘要填写完整。" : "请完成五类售后政策及三项详情页售后信息。" }, + }); + } + return request({ + url: "/api/student-training-answers/after-sales-service/step-3/validate", + method: "post", + data: { policies, summaries }, + headers: { repeatSubmit: false }, + }); +} + +export function checkProductArchitectureDesignStepTwo(actionIds) { + const expected = ["user-action", "hardware-senses", "driver-transmits", "algorithm-processes", "application-triggers", "driver-outputs", "hardware-effects"]; + const items = Array.isArray(actionIds) ? actionIds : []; + if (isStudentDemo()) { + const correctPositionCount = expected.reduce((total, id, index) => total + (items[index] === id ? 1 : 0), 0); + return Promise.resolve({ + code: 200, + data: { + correctPositionCount, + totalCount: expected.length, + allCorrect: items.length === expected.length && correctPositionCount === expected.length, + message: correctPositionCount === expected.length ? "排序正确,已解锁下一步。" : "排序尚未正确,请继续调整。", + }, + }); + } + return request({ + url: "/api/student-training-answers/product-structure/step-2/check", + method: "post", + data: { actionIds: items }, + headers: { repeatSubmit: false }, + }); +} + +export function checkProductArchitectureDesignStepThree(rows) { + const items = Array.isArray(rows) ? rows : []; + const requiredFields = ["userScenario", "hardwareSense", "driverTransmit", "algorithmProcess", "applicationTrigger", "driverOutput", "hardwareEffect"]; + if (isStudentDemo()) { + const valid = items.length >= 3 && items.length <= 10 && items.every((row) => requiredFields.every((field) => Boolean(String(row?.[field] || "").trim()))); + return Promise.resolve({ + code: 200, + data: { valid, message: valid ? "场景架构分析填写完整。" : "请至少完整填写 3 个典型工作场景。" }, + }); + } + return request({ + url: "/api/student-training-answers/product-structure/step-3/validate", + method: "post", + data: { rows: items }, + headers: { repeatSubmit: false }, + }); +} + +export function checkProductArchitectureDesignStepFour(images) { + const files = Array.isArray(images) ? images : []; + if (isStudentDemo()) { + const valid = files.length === 3 && files.every((item) => typeof item === "string" && item.trim()); + return Promise.resolve({ + code: 200, + data: { valid, message: valid ? "三张核心功能架构图已提交。" : "请上传三张核心功能架构图。" }, + }); + } + return request({ + url: "/api/student-training-answers/product-structure/step-4/validate", + method: "post", + data: { images: files }, + headers: { repeatSubmit: false }, + }); +} + export function checkProductDevelopmentProcessStepTwo(items) { if (isStudentDemo()) { const rows = Array.isArray(items) ? items : []; @@ -488,6 +1177,27 @@ export function previewConsumerBehaviorQuestionnaireWord(data) { }); } +export function previewRequirementsDocumentFile(data) { + if (isStudentDemo()) { + return Promise.resolve({ + code: 200, + data: { + fileType: "DEMO", + content: "演示模式下仅展示已选择文件的信息;登录学生账号后可解析并在线预览 Word 或 PDF 的正文内容。", + }, + }); + } + return request({ + url: "/api/student-training-answers/requirements-doc/step-3/document-preview", + method: "post", + data, + headers: { + "Content-Type": "multipart/form-data", + repeatSubmit: false, + }, + }); +} + export function checkConsumerBehaviorStepFour(payload) { if (isStudentDemo()) { const rows = Array.isArray(payload?.rows) ? payload.rows : []; @@ -591,3 +1301,335 @@ export function checkProductValuePositioningStepFour(payload) { headers: { repeatSubmit: false }, }); } + +export function validateHardwareCostStepOne(payload) { + const items = Array.isArray(payload?.items) ? payload.items : []; + if (isStudentDemo()) { + const normalizedItems = items.map((item) => { + const unitPrice = Number(item?.unitPrice); + const quantity = Number(item?.quantity); + return { + materialName: String(item?.materialName || "").trim(), + specification: String(item?.specification || "").trim(), + unitPrice, + quantity, + amount: Number.isFinite(unitPrice) && Number.isFinite(quantity) + ? Number((unitPrice * quantity).toFixed(2)) + : 0, + }; + }); + const valid = normalizedItems.length >= 10 + && normalizedItems.length <= 40 + && normalizedItems.every((item) => item.materialName && item.specification + && Number.isFinite(item.unitPrice) && item.unitPrice >= 0 + && Number.isInteger(item.quantity) && item.quantity > 0); + if (!valid) { + return Promise.reject(new Error("请完整填写 10 至 40 行 BOM 物料明细,单价不能为负且数量必须大于 0")); + } + return Promise.resolve({ + code: 200, + data: { + valid: true, + message: "校验通过", + items: normalizedItems, + totalAmount: Number(normalizedItems.reduce((total, item) => total + item.amount, 0).toFixed(2)), + }, + }); + } + return request({ + url: "/api/student-training-answers/hardware-cost/step-1/validate", + method: "post", + data: { items }, + headers: { repeatSubmit: false }, + }); +} + +export function validateHardwareCostStepTwo(payload) { + const value = payload || {}; + const items = Array.isArray(value.items) ? value.items : []; + if (isStudentDemo()) { + const smallBatchConstraint = Number(value.smallBatchConstraint); + const largeBatchConstraint = Number(value.largeBatchConstraint); + const normalizedItems = items.map((item) => ({ + materialName: String(item?.materialName || "").trim(), + smallBatchUnitCost: Number(item?.smallBatchUnitCost), + largeBatchUnitCost: Number(item?.largeBatchUnitCost), + })); + const validRows = normalizedItems.length > 0 && normalizedItems.every((item) => item.materialName + && Number.isFinite(item.smallBatchUnitCost) && item.smallBatchUnitCost >= 0 + && Number.isFinite(item.largeBatchUnitCost) && item.largeBatchUnitCost >= 0); + if (!validRows || !Number.isFinite(smallBatchConstraint) || smallBatchConstraint < 0 + || !Number.isFinite(largeBatchConstraint) || largeBatchConstraint < 0) { + return Promise.reject(new Error("请完整填写两种批量的物料成本和成本约束")); + } + const smallBatchTotal = Number(normalizedItems.reduce((sum, item) => sum + item.smallBatchUnitCost, 0).toFixed(2)); + const largeBatchTotal = Number(normalizedItems.reduce((sum, item) => sum + item.largeBatchUnitCost, 0).toFixed(2)); + const smallBatchWithinConstraint = smallBatchTotal <= smallBatchConstraint; + const largeBatchWithinConstraint = largeBatchTotal <= largeBatchConstraint; + return Promise.resolve({ + code: 200, + data: { + valid: smallBatchWithinConstraint && largeBatchWithinConstraint, + message: smallBatchWithinConstraint && largeBatchWithinConstraint + ? "两种批量的 BOM 成本均满足约束" + : "合计成本超出填写的成本约束", + items: normalizedItems, + smallBatchConstraint: Number(smallBatchConstraint.toFixed(2)), + largeBatchConstraint: Number(largeBatchConstraint.toFixed(2)), + smallBatchTotal, + largeBatchTotal, + smallBatchWithinConstraint, + largeBatchWithinConstraint, + }, + }); + } + return request({ + url: "/api/student-training-answers/hardware-cost/step-2/validate", + method: "post", + data: value, + headers: { repeatSubmit: false }, + }); +} + +export function validateHardwareCostStepThree(payload) { + const value = payload || {}; + const items = Array.isArray(value.items) ? value.items : []; + if (isStudentDemo()) { + if (items.length < 6 || items.length > 15) { + return Promise.reject(new Error("请填写 6 至 15 项制造费用。")); + } + const normalizedItems = items.map((item, index) => { + const expenseName = String(item?.expenseName || "").trim(); + const smallBatchUnitCost = Number(item?.smallBatchUnitCost); + const largeBatchUnitCost = Number(item?.largeBatchUnitCost); + if (!expenseName || !Number.isFinite(smallBatchUnitCost) || !Number.isFinite(largeBatchUnitCost) + || smallBatchUnitCost < 0 || largeBatchUnitCost < 0) { + throw new Error("请完整填写第 " + (index + 1) + " 项制造费用及两种批量成本。"); + } + return { + expenseName, + smallBatchUnitCost: Number(smallBatchUnitCost.toFixed(2)), + largeBatchUnitCost: Number(largeBatchUnitCost.toFixed(2)), + }; + }); + const saved = demoResult("hardware-cost"); + const stepTwo = typeof saved?.step2Answer === "string" ? JSON.parse(saved.step2Answer) : saved?.step2Answer; + const smallBatchBomCost = Number(stepTwo?.smallBatchTotal); + const largeBatchBomCost = Number(stepTwo?.largeBatchTotal); + if (!Number.isFinite(smallBatchBomCost) || !Number.isFinite(largeBatchBomCost)) { + return Promise.reject(new Error("请先完成并保存第 2 步物料 BOM 成本核算。")); + } + const smallBatchManufacturingCost = Number(normalizedItems.reduce((sum, item) => sum + item.smallBatchUnitCost, 0).toFixed(2)); + const largeBatchManufacturingCost = Number(normalizedItems.reduce((sum, item) => sum + item.largeBatchUnitCost, 0).toFixed(2)); + return Promise.resolve({ + code: 200, + data: { + valid: true, + message: "制造成本与单件总成本已核算完成。", + items: normalizedItems, + smallBatchManufacturingCost, + largeBatchManufacturingCost, + smallBatchBomCost: Number(smallBatchBomCost.toFixed(2)), + largeBatchBomCost: Number(largeBatchBomCost.toFixed(2)), + smallBatchUnitTotalCost: Number((smallBatchManufacturingCost + smallBatchBomCost).toFixed(2)), + largeBatchUnitTotalCost: Number((largeBatchManufacturingCost + largeBatchBomCost).toFixed(2)), + }, + }); + } + return request({ + url: "/api/student-training-answers/hardware-cost/step-3/validate", + method: "post", + data: value, + headers: { repeatSubmit: false }, + }); +} + +export function validateHardwareCostStepFour(payload) { + const value = payload || {}; + if (isStudentDemo()) { + const smallBatchChartUrl = String(value.smallBatchChartUrl || "").trim(); + const largeBatchChartUrl = String(value.largeBatchChartUrl || "").trim(); + const optimizationPlan = String(value.optimizationPlan || "").trim(); + if (!smallBatchChartUrl || !largeBatchChartUrl) { + return Promise.reject(new Error("请分别上传小批量试产和大批量量产成本结构图")); + } + if (!optimizationPlan) return Promise.reject(new Error("请填写成本优化决策")); + if (optimizationPlan.length > 3000) return Promise.reject(new Error("成本优化决策不能超过 3000 字")); + return Promise.resolve({ code: 200, data: { valid: true, message: "成本结构图与优化决策校验通过" } }); + } + return request({ + url: "/api/student-training-answers/hardware-cost/step-4/validate", + method: "post", + data: value, + headers: { repeatSubmit: false }, + }); +} + +export function validateSupplierEvaluationStepOne(payload) { + const items = Array.isArray(payload?.items) ? payload.items : []; + if (isStudentDemo()) { + const expectedDimensions = ["采购成本", "交货周期", "品质可靠性", "采购灵活性", "服务支持"]; + const normalizedItems = items.map((item) => ({ + dimension: String(item?.dimension || "").trim(), + smallBatchWeight: Number(item?.smallBatchWeight), + largeBatchWeight: Number(item?.largeBatchWeight), + })); + const hasAllDimensions = normalizedItems.length === expectedDimensions.length + && normalizedItems.every((item, index) => item.dimension === expectedDimensions[index]); + const validNumbers = normalizedItems.every((item) => Number.isFinite(item.smallBatchWeight) + && Number.isFinite(item.largeBatchWeight) + && item.smallBatchWeight >= 0 && item.smallBatchWeight <= 100 + && item.largeBatchWeight >= 0 && item.largeBatchWeight <= 100); + const smallBatchTotal = Number(normalizedItems.reduce((sum, item) => sum + item.smallBatchWeight, 0).toFixed(2)); + const largeBatchTotal = Number(normalizedItems.reduce((sum, item) => sum + item.largeBatchWeight, 0).toFixed(2)); + if (!hasAllDimensions || !validNumbers || smallBatchTotal !== 100 || largeBatchTotal !== 100) { + return Promise.reject(new Error("请完整填写 5 个评估维度,且小批量试产与大批量量产的权重合计均为 100%")); + } + return Promise.resolve({ + code: 200, + data: { valid: true, message: "双场景权重校验通过", items: normalizedItems, smallBatchTotal, largeBatchTotal }, + }); + } + return request({ + url: "/api/student-training-answers/supplier-evaluation/step-1/validate", + method: "post", + data: { items }, + headers: { repeatSubmit: false }, + }); +} + +export function validateSupplierEvaluationStepTwo(payload) { + const value = payload || {}; + const expectedDimensions = ["采购成本", "交货周期", "品质可靠性", "采购灵活性", "服务支持"]; + if (isStudentDemo()) { + try { + const saved = demoResult("supplier-evaluation"); + const stepOne = typeof saved?.step1Answer === "string" ? JSON.parse(saved.step1Answer) : saved?.step1Answer; + const weights = Array.isArray(stepOne?.items) ? stepOne.items : []; + if (weights.length !== expectedDimensions.length + || weights.some((item, index) => item?.dimension !== expectedDimensions[index])) { + throw new Error("请先完成并保存第 1 步的供应渠道权重设置"); + } + const normalize = (rows, isSmallBatch, expectedChannels) => { + if (!Array.isArray(rows) || rows.length !== 5) throw new Error("试产和量产场景均需填写 5 个供应渠道"); + return rows.map((row, rowIndex) => { + const channel = String(row?.channel || "").trim(); + if (!channel) throw new Error(`请填写第 ${rowIndex + 1} 行供应渠道名称`); + if (expectedChannels && channel !== expectedChannels[rowIndex]) { + throw new Error("量产场景的供应渠道必须与试产场景保持一致"); + } + const scores = Array.isArray(row?.scores) ? row.scores : []; + if (scores.length !== expectedDimensions.length) throw new Error(`请完整填写“${channel}”的 5 个维度评分`); + const normalizedScores = scores.map((score, index) => { + const numeric = Number(score?.score); + if (score?.dimension !== expectedDimensions[index] || !Number.isInteger(numeric) || numeric < 0 || numeric > 10) { + throw new Error(`请为“${channel}”填写 0 到 10 的整数评分:${expectedDimensions[index]}`); + } + return { dimension: expectedDimensions[index], score: numeric }; + }); + const weightedTotal = Number(normalizedScores.reduce((sum, score, index) => { + const weight = Number(isSmallBatch ? weights[index]?.smallBatchWeight : weights[index]?.largeBatchWeight); + return sum + score.score * weight / 100; + }, 0).toFixed(2)); + return { channel, scores: normalizedScores, weightedTotal }; + }); + }; + const smallBatchChannels = normalize(value.smallBatchChannels, true); + const largeBatchChannels = normalize(value.largeBatchChannels, false, smallBatchChannels.map((item) => item.channel)); + return Promise.resolve({ code: 200, data: { valid: true, message: "两种采购场景的供应渠道加权得分已重新核算。", weights, smallBatchChannels, largeBatchChannels } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/supplier-evaluation/step-2/validate", + method: "post", + data: value, + headers: { repeatSubmit: false }, + }); +} + +export function validateSupplierEvaluationStepThree(payload) { + if (isStudentDemo()) { + try { + const value = payload || {}; + const saved = demoResult("supplier-evaluation"); + const stepTwo = typeof saved?.step2Answer === "string" + ? JSON.parse(saved.step2Answer || "{}") + : (saved?.step2Answer || {}); + const scenarioFields = { + smallBatch: "smallBatchChannels", + largeBatch: "largeBatchChannels", + }; + const suppliedAnalyses = Array.isArray(value.scenarioAnalyses) ? value.scenarioAnalyses : []; + + const scenarioAnalyses = Object.keys(scenarioFields).map((scenario) => { + const analysis = suppliedAnalyses.find((item) => item?.scenario === scenario); + if (!analysis || !String(analysis.differenceAnalysis || "").trim()) { + throw new Error("请填写两种采购场景的分差与分析"); + } + const ranked = (Array.isArray(stepTwo[scenarioFields[scenario]]) ? stepTwo[scenarioFields[scenario]] : []) + .map((item) => ({ + channel: String(item?.channel || "").trim(), + score: Number(item?.weightedTotal), + })) + .filter((item) => item.channel && Number.isFinite(item.score)) + .sort((left, right) => right.score - left.score || left.channel.localeCompare(right.channel, "zh-CN")); + if (ranked.length < 2) { + throw new Error("请先完成第二步供应渠道评分"); + } + if (analysis.bestChannel !== ranked[0].channel || analysis.secondChannel !== ranked[1].channel) { + throw new Error("渠道排名已更新,请确认第二步评分结果后再保存"); + } + return { + scenario, + bestChannel: ranked[0].channel, + secondChannel: ranked[1].channel, + bestScore: ranked[0].score.toFixed(2), + secondScore: ranked[1].score.toFixed(2), + scoreGap: (ranked[0].score - ranked[1].score).toFixed(2), + differenceAnalysis: String(analysis.differenceAnalysis).trim(), + }; + }); + + const procurementRecommendation = String(value.procurementRecommendation || "").trim(); + if (!procurementRecommendation) { + throw new Error("请填写综合采购建议"); + } + const risks = Array.isArray(value.risks) ? value.risks : []; + if (risks.length < 4 || risks.length > 10) { + throw new Error("供应风险需保留至少 4 条,最多 10 条"); + } + const normalizedRisks = risks.map((risk) => { + const normalized = { + dimension: String(risk?.dimension || "").trim(), + description: String(risk?.description || "").trim(), + level: String(risk?.level || "").trim(), + measure: String(risk?.measure || "").trim(), + }; + if (!normalized.dimension || !normalized.description || !normalized.measure || !["高", "中", "低"].includes(normalized.level)) { + throw new Error("请完整填写每条供应风险及应对措施"); + } + return normalized; + }); + return Promise.resolve({ + code: 200, + data: { + valid: true, + message: "校验通过", + scenarioAnalyses, + procurementRecommendation, + risks: normalizedRisks, + }, + }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ + url: "/api/student-training-answers/supplier-evaluation/step-3/validate", + method: "post", + data: payload, + headers: { repeatSubmit: false }, + }); +} diff --git a/src/views/assessment/diagnosis.vue b/src/views/assessment/diagnosis.vue index b7ed44b..0e868d3 100644 --- a/src/views/assessment/diagnosis.vue +++ b/src/views/assessment/diagnosis.vue @@ -1,73 +1,76 @@ diff --git a/src/views/assessment/optimization.vue b/src/views/assessment/optimization.vue index 3ad4abc..e0af0d5 100644 --- a/src/views/assessment/optimization.vue +++ b/src/views/assessment/optimization.vue @@ -1,82 +1,359 @@ +const taskRequirement = computed(() => taskConfig.value?.requirements || "根据案例数据完成品类营收排序、占比与累计占比分析。"); +const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解品类营收结构", "掌握累计营收占比计算", "形成可执行的品类结构优化建议"])); +const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps)); +const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0); +const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || "品类营收数据整理"); +const isStepOne = computed(() => !hasConfiguredSteps.value || activeStep.value === 1); +const isStepTwo = computed(() => hasConfiguredSteps.value && activeStep.value === 2); +const isStepThree = computed(() => hasConfiguredSteps.value && activeStep.value === 3); +const isStepFour = computed(() => hasConfiguredSteps.value && activeStep.value === 4); +const isStepFive = computed(() => hasConfiguredSteps.value && activeStep.value === 5); +const selectedProduct = computed(() => categoryRows.value.find((row) => row.categoryName === stepFour.value.productName) || null); +const currentClassificationLabel = computed(() => selectedProduct.value?.abcClassification ? `${selectedProduct.value.abcClassification}类${selectedProduct.value.abcClassification === "A" ? "核心品类" : selectedProduct.value.abcClassification === "B" ? "潜力品类" : "长尾品类"}` : "选择新品后自动带出"); +const progressItems = computed(() => configuredSteps.value.map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" }))); - diff --git a/src/views/channel/index.vue b/src/views/channel/index.vue index c768b30..b762b46 100644 --- a/src/views/channel/index.vue +++ b/src/views/channel/index.vue @@ -8,121 +8,92 @@

Step {{ String(activeStep).padStart(2,'0') }}

{{ activeStepName }}

-

季节指数预测法,亦称“温斯特法”,是根据时间序列中的数据资料所呈现的季节变动规律性,对预测目标未来状况作出预测的方法。它首先建立预测模型来求得历史上各期的趋势值,然后用实际值除以趋势值来求出季节指数。若是有两个以上的波动周期,还需要求出相同时期的季节指数平均值,再用它去修正预测值。

-
- - 实验实训 +

线性预测的核心是拟合趋势线(y=a+bt),是基于历史数据预测未来销量的有效方法。根据案例资料及数据,预测案例店铺产品未来 3 个月市场需求数据。

+
+ + + + + +
-

假设某电商平台店铺主营夏季产品,如泳装、泳帽、泳镜、游泳圈等产品,这类产品具有明显的季节波动性,公司需要预测未来12个月的采购需求,请使用季节指数预测法完成任务。

-

- 应用步骤 -
- 数据收集:收集历史采购数据,确保数据覆盖多个完整的季节周期。 -
- 数据预处理:清理数据,处理缺失值和异常值。 -
- 计算移动平均值:计算每个时间点的移动平均值,以平滑数据。 -
- 计算季节指数:通过将实际值除以移动平均值,计算每个月份的季节指数。 -
- 预测未来需求:利用季节指数和趋势数据预测未来的采购需求。 -
-

-
- 1.数据准备 - 数据下载 -
-

公司2022到2024年三年期间月度采购量如下所示:

- - - - - - - - - - - - - - - -

- 数据特征说明‌: -
- ‌季节性规律‌ -
- ‌旺季(6-8月)‌:夏季需求高峰,采购量逐月攀升,7月达年度峰值(如2023年7月920件); -
- ‌淡季(12-2月)‌:冬季需求低迷,采购量显著低于其他月份(如2024年1月140件)。 -
-

-

- 年度增长趋势‌ -
- 每年采购量整体增长约 ‌5%-10%‌,符合行业稳定扩张特征(如2023年6月较2022年增长8.3%); -
- 冬季淡季增速较低(如2023年12月较2022年增长5.6%),夏季旺季增速较高(如2024年7月较2023年增长3.3%)。 -
-

-

- 过渡月份波动‌ -
- 春季(3-5月)和秋季(9-11月)为过渡期,采购量随气温变化逐步上升或下降(如4月采购量介于300-400件); -
- 9月后需求快速回落,反映季节性商品换季特征(如2022年9月采购量环比下降33%) -
-

+
+
+

可参考以下方法:

+
    +
  1. 下载案例数据表,使用 Excel 中的 FORECAST.LINEAR 函数(WPS 为 FORECAST 函数),自行预测未来 3 个月市场需求。
  2. +
  3. 导出实训数据表,上传到通用 AI 大模型,通过大模型计算结果。参考提示词如下:请根据 12 个月销量数据,利用线性预测法预测未来三个月的销量数据,给出计算过程。
  4. +
+
+ 下载实训操作说明 +

Step {{ String(activeStep).padStart(2,'0') }}

{{ activeStepName }}

-

月度数据我们采用12期移动平均,计算简单移动平均值数据,计算结果取整:

- - - - - - - +

计算未来 3 个月需求量后,使用散点图 + 趋势线绘制需求量预测趋势图,呈现已知的 12 个月销量以及预测数据的整体走势。

+
+ +
+ +
+ {{ demandForecastChart.name || "需求预测可视化图形" }} + 点击图形可放大预览 + +
+
+
+
+
+

可参考以下方法:

+
    +
  1. 使用 Excel/WPS 绘图功能实现可视化,可参考《实训操作说明》完成。
  2. +
  3. 下载实训数据表,加上预测的三个月需求数据,表格整体数据上传到通用 AI 大模型,通过大模型计算结果生成可视化图形。参考提示词:请根据表格数据,生成数据趋势图,要求使用散点图加趋势图绘制数据走势。
  4. +
+
+ 下载实训操作说明 +

Step {{ String(activeStep).padStart(2,'0') }}

{{ activeStepName }}

-

计算季节指数,先用上述各月份实际值除以移动平均值,计算季节性不规则成分,然后按月份分组求均值,得出初版季节性指数,最后再用各月份初版季节性指数除以初版季节性指数的均值,得出标准化季节性指数,请在下表填写标准化季节性指数(结果保留3位小数):

- - - - - - +

基于步骤一预测的未来3个月销量,结合安全库存和期初库存,计算各月采购量,填报采购预测表。

+
+

采购规则

+
    +
  1. 安全库存:按预测销量的 10% 预留,应对突发订单波动(安全库存 = 预测销量 × 10%,四舍五入取整)。
  2. +
  3. 补货周期:按月采购,每月月初下达采购订单。
  4. +
  5. 期初库存:第 12 月末剩余库存为 30 台,作为预测第 1 个月(t=13)的期初库存;此后各月期初库存为上月末剩余库存。
  6. +
+

月度采购量 = 预测销量 + 安全库存 - 期初库存

+
+
+ + + + + + + + + + + + + + + +
+

预测销量由第 1 步同步带入。提交时将按题设规则校验安全库存、期初库存和采购量。

Step {{ String(activeStep).padStart(2,'0') }}

{{ activeStepName }}

未来需求预测,使用线性回归模型预测2025年采购量:

- + - diff --git a/src/views/components/TrainingMaterialButton.vue b/src/views/components/TrainingMaterialButton.vue index 99b4fb0..b087ba3 100644 --- a/src/views/components/TrainingMaterialButton.vue +++ b/src/views/components/TrainingMaterialButton.vue @@ -1,40 +1,39 @@ diff --git a/src/views/product/after-sales-service.vue b/src/views/product/after-sales-service.vue index 78104f4..3539ba9 100644 --- a/src/views/product/after-sales-service.vue +++ b/src/views/product/after-sales-service.vue @@ -2,105 +2,190 @@
-
核心实训 · 产品规划与设计
+
产品规划与设计

{{ taskTitle }}

-
+
+
+

After-sales diagnostic routing

+

售后故障类型分类

+
-