You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/assessment/optimization.vue

366 lines
39 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="optimization student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge"><el-icon><TrendCharts /></el-icon> · 线广</div>
<h1>{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">CATEGORY OPERATIONS</p>
<h2>{{ activeStepName }}</h2>
</div>
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button class="outline-action" type="button" @click="exportOutcome"><el-icon><Download /></el-icon></button>
</div>
</div>
<nav v-if="hasConfiguredSteps" class="step-indicator" aria-label="品类结构优化步骤">
<template v-for="(step, index) in configuredSteps" :key="`${step}-${index}`">
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]" @click="setCurrentStep(index + 1)">
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ step }}
</button>
</template>
</nav>
<section v-if="isStepOne" class="category-step">
<p class="instruction">根据案例资料提供的店铺各品类近12个月营收数据对各品类按照销售额从大到小进行排序并计算营收占比和累计营收占比完善数据表。</p>
<div class="table-toolbar">
<span>已填写 {{ categoryRows.length }} / 30 行</span>
<div>
<button type="button" class="action-button" :disabled="categoryRows.length >= MAX_ROWS" @click="addRow"><el-icon><Plus /></el-icon>增加一行</button>
<button type="button" class="action-button action-button--muted" :disabled="!categoryRows.length" @click="removeLastRow"><el-icon><Minus /></el-icon>删除一行</button>
</div>
</div>
<div class="category-table-wrap">
<table class="category-table">
<thead><tr><th>品类名称</th><th>近12个月营收万元</th><th>营收占比(%</th><th>累计占比(%</th><th>库存资金占用(万元)</th><th>资金占用率</th><th>近30天动销率</th><th>毛利率</th></tr></thead>
<tbody>
<tr v-for="(row, index) in categoryRows" :key="row.id">
<td><el-input v-model="row.categoryName" :aria-label="`第${index + 1}行品类名称`" /></td>
<td><el-input v-model="row.revenue" inputmode="decimal" :aria-label="`第${index + 1}行近12个月营收`" /></td>
<td><el-input v-model="row.revenueShare" inputmode="decimal" :aria-label="`第${index + 1}行营收占比`" /></td>
<td><el-input v-model="row.cumulativeShare" inputmode="decimal" :aria-label="`第${index + 1}行累计占比`" /></td>
<td><el-input v-model="row.inventoryCapital" inputmode="decimal" :aria-label="`第${index + 1}行库存资金占用`" /></td>
<td><el-input v-model="row.capitalOccupancyRate" inputmode="decimal" :aria-label="`第${index + 1}行资金占用率`" /></td>
<td><el-input v-model="row.sellThroughRate" inputmode="decimal" :aria-label="`第${index + 1}行近30天动销率`" /></td>
<td><el-input v-model="row.grossMargin" inputmode="decimal" :aria-label="`第${index + 1}行毛利率`" /></td>
</tr>
<tr v-if="!categoryRows.length"><td colspan="8" class="empty-cell">暂无数据,请根据案例资料增加行并填写。</td></tr>
</tbody>
</table>
</div>
<div class="rule-notice">
<strong>填写与校验说明</strong>
<p>默认无数据学生自行根据案例资料增加行填写数据最多30行。</p>
<p>近12个月营收须从高到低排列营收占比 = 当前行近12个月营收 ÷ 所有品类营收之和;累计占比 = 从第一行到当前行的营收占比之和。三项逻辑不符时将提示,且不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepTwo" class="category-step">
<p class="instruction">累计营收占比前70%的品类划分为A类核心品类70%90%区间的品类划分为B类潜力品类最后10%的品类划分为C类长尾品类。请按照上述标准完成ABC分层。</p>
<div class="category-table-wrap">
<table class="category-table abc-table">
<thead><tr><th>品类名称</th><th>营收贡献(万元)</th><th>销售额占比(%</th><th>累计占比(%</th><th>ABC分层判定</th></tr></thead>
<tbody>
<tr v-for="(row, index) in categoryRows" :key="row.id">
<td>{{ row.categoryName }}</td>
<td>{{ row.revenue }}</td>
<td>{{ row.revenueShare }}</td>
<td>{{ row.cumulativeShare }}</td>
<td>
<el-select v-model="row.abcClassification" placeholder="请选择" :aria-label="`第${index + 1}行ABC分层判定`">
<el-option label="A类核心品类" value="A" />
<el-option label="B类潜力品类" value="B" />
<el-option label="C类长尾品类" value="C" />
</el-select>
</td>
</tr>
<tr v-if="!categoryRows.length"><td colspan="5" class="empty-cell">第一步暂无可同步的数据,请先完成第一步。</td></tr>
</tbody>
</table>
</div>
<div class="rule-notice">
<strong>ABC分层校验说明</strong>
<p>本表的品类名称、营收贡献、销售额占比和累计占比与第一步同步仅需选择ABC分层判定。</p>
<p>累计营收占比 ≤70% 为A类>70%且≤90%为B类>90%为C类。判定不符合时将提示且不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepThree" class="category-step pareto-step">
<p class="instruction">根据步骤二数据绘制帕累托图柱状图为各品类营收贡献降序排列折线图为累计占比并标注70%和90%分界线直观展示ABC分层结果。</p>
<div class="pareto-upload-panel">
<input ref="paretoInput" class="file-input" type="file" accept="image/*" @change="handleParetoUpload" />
<template v-if="!paretoImage.url">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<strong>上传帕累托图</strong>
<span>支持 JPG、PNG、WEBP 等图片格式</span>
<button type="button" class="action-button" :disabled="uploadingPareto" @click="paretoInput?.click()">选择图片</button>
</template>
<template v-else>
<el-image class="pareto-preview" :src="paretoImage.url" :preview-src-list="[paretoImage.url]" fit="contain" preview-teleported />
<div class="upload-file-meta"><strong>{{ paretoImage.name || "已上传帕累托图" }}</strong><span>点击图片可预览大图</span></div>
<div class="pareto-actions"><button type="button" class="outline-action" :disabled="uploadingPareto" @click="paretoInput?.click()"><el-icon><UploadFilled /></el-icon>重新上传</button><button type="button" class="outline-action danger-action" @click="removeParetoImage"><el-icon><Delete /></el-icon>移除图片</button></div>
</template>
</div>
<div class="rule-notice">
<strong>上传校验说明</strong>
<p>帕累托图必须根据步骤二已完成的ABC分层数据绘制未上传图片时不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepFour" class="category-step strategy-step">
<p class="instruction">作为战略新品通常营收贡献较低而被归入C类长尾品类但新品具备技术领先性和市场增长潜力需要通过矩阵定位确定流量扶持与分层优化方向。</p>
<h3 class="subsection-heading">(一)品类矩阵定位</h3>
<p class="subsection-copy">根据案例资料及新品的目标客群,定位新品的品类矩阵。</p>
<div class="category-matrix" aria-label="市场增长率与客群重叠度矩阵">
<span class="matrix-y-title">市场增长率</span><span class="matrix-y-high">高</span><span class="matrix-y-low">低</span><span class="matrix-x-title">客群重叠度</span><span class="matrix-x-high">高</span><span class="matrix-x-low">低</span>
<div class="matrix-grid"><div class="matrix-cell"><strong>新品渠道孵化</strong><small>需独立引流</small></div><div class="matrix-cell"><strong>品类延伸</strong><small>可直接向老客推广</small></div><div class="matrix-cell"><strong>放弃或外包</strong></div><div class="matrix-cell"><strong>防御性布局</strong><small>保持存在即可</small></div></div>
</div>
<div class="matrix-inputs">
<label>新品矩阵位置<el-select v-model="stepFour.matrixQuadrant" placeholder="请选择矩阵位置"><el-option label="新品渠道孵化(高增长、低重叠)" value="新品渠道孵化" /><el-option label="品类延伸(高增长、高重叠)" value="品类延伸" /><el-option label="放弃或外包(低增长、低重叠)" value="放弃或外包" /><el-option label="防御性布局(低增长、高重叠)" value="防御性布局" /></el-select></label>
<label>定位原因<el-input v-model="stepFour.matrixReason" type="textarea" :rows="3" placeholder="说明新品位于该矩阵位置的原因" /></label>
</div>
<div class="rule-notice"><strong>矩阵阅读说明</strong><p>横轴、纵轴均从左下角原点开始:向右表示客群重叠度从低到高,向上表示市场增长率从低到高。</p></div>
<h3 class="subsection-heading">(二)新品分层优化</h3>
<p class="subsection-copy">结合品类分析结果,提出新品分层优化建议。</p>
<div class="category-table-wrap">
<table class="category-table strategy-table"><thead><tr><th>新品名称</th><th>当前分层</th><th>调整必要性分析</th><th>建议调整分层</th><th>流量扶持措施</th></tr></thead><tbody><tr><td><el-select v-model="stepFour.productName" placeholder="请选择新品" @change="handleProductChange"><el-option v-for="row in categoryRows" :key="row.id" :label="row.categoryName" :value="row.categoryName" /></el-select></td><td><span class="current-classification">{{ currentClassificationLabel }}</span></td><td><el-input v-model="stepFour.necessityAnalysis" type="textarea" :rows="3" placeholder="分析调整分层的必要性" /></td><td><el-select v-model="stepFour.recommendedClassification" placeholder="请选择"><el-option label="A类核心品类" value="A" :disabled="selectedProduct?.abcClassification === 'A'" /><el-option label="B类潜力品类" value="B" :disabled="selectedProduct?.abcClassification === 'B'" /><el-option label="C类长尾品类" value="C" :disabled="selectedProduct?.abcClassification === 'C'" /></el-select></td><td><el-input v-model="stepFour.trafficSupportMeasures" type="textarea" :rows="3" placeholder="填写流量扶持措施" /></td></tr></tbody></table>
</div>
<div class="rule-notice"><strong>同步与校验说明</strong><p>新品名称只能选择第一步已填写的品类;当前分层由第二步结果自动带出。建议调整分层必须不同于当前分层,且矩阵定位、原因、必要性分析与流量扶持措施均须完整填写。</p></div>
</section>
<section v-else-if="isStepFive" class="category-step strategy-step">
<p class="instruction">根据ABC分层结果针对A类核心品类、B类潜力品类、C类长尾品类分别制定差异化的运营策略、库存策略和资源配置方案结合案例资料提供的SKU数、动销率、毛利率、库存周转率等经营数据填写下表。</p>
<div class="category-table-wrap">
<table class="category-table operations-table"><thead><tr><th>ABC分层</th><th>运营策略</th><th>库存策略</th><th>资源配置优先级</th></tr></thead><tbody><tr v-for="row in strategyRows" :key="row.layer"><td class="layer-cell">{{ row.label }}</td><td><el-input v-model="row.operationStrategy" type="textarea" :rows="3" :placeholder="row.operationPlaceholder" /></td><td><el-input v-model="row.inventoryStrategy" type="textarea" :rows="3" :placeholder="row.inventoryPlaceholder" /></td><td><el-select v-model="row.resourcePriority" placeholder="请选择"><el-option label="最高优先级" value="最高优先级" /><el-option label="次级优先级" value="次级优先级" /><el-option label="较低优先级" value="较低优先级" /></el-select></td></tr></tbody></table>
</div>
<div class="rule-notice"><strong>填写说明</strong><p>A、B、C类和新品四行均需填写。运营、库存策略应结合案例经营数据资源配置优先级需明确选择未完整填写时不能提交任务。</p></div>
</section>
<section v-else class="analysis-step">
<div class="analysis-tabs" role="tablist" aria-label="品类分析工具">
<button v-for="(item, index) in modules" :key="item.name" type="button" :class="{ active: activeModule === index }" @click="setActiveModule(index)">{{ item.name }}</button>
</div>
<div class="business-workspace"><component :is="activeComponent" :key="workspaceKey" ref="businessComponent" /></div>
</section>
<div v-if="hasConfiguredSteps" class="step-navigation">
<button type="button" class="nav-button" :disabled="activeStep === 1" @click="setCurrentStep(activeStep - 1)"><el-icon><ArrowLeft /></el-icon>上一步</button>
<button type="button" class="nav-button nav-button--save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button>
<button type="button" class="nav-button nav-button--primary" :disabled="activeStep === configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button>
</div>
<div class="training-actions">
<el-button class="training-action" :loading="saving" @click="submitTask">提交任务</el-button>
<el-button class="training-action" :loading="saving" @click="resetTask">清空填写</el-button>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="先完成品类营收数据排序和占比校验,再开展后续分析。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { ArrowLeft, ArrowRight, CircleCheck, Delete, Download, Minus, Plus, TrendCharts, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateCategoryOptimizationStepOne, validateCategoryOptimizationStepTwo, validateCategoryOptimizationStepThree, validateCategoryOptimizationStepFour, validateCategoryOptimizationStepFive } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "category-optimization";
const MAX_ROWS = 30;
const taskConfig = ref(null);
const activeStep = ref(1);
const activeModule = ref(0);
const saving = ref(false);
const workspaceKey = ref(0);
const businessComponent = ref(null);
const categoryRows = ref([]);
const paretoInput = ref(null);
const uploadingPareto = ref(false);
const paretoImage = ref({ url: "", name: "" });
const stepFour = ref(createStepFour());
const strategyRows = ref(createStrategyRows());
const modules = [
{ name: "帕累托 / ABC 分析", component: defineAsyncComponent(() => import("../portraitModel/components/abcAnalysis.vue")) },
{ name: "购物篮分析", component: defineAsyncComponent(() => import("../portraitModel/components/basketAnalysis.vue")) },
];
const activeComponent = computed(() => modules[activeModule.value].component);
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 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" : "" })));
function createRow(source = {}) { return { id: source.id || `${Date.now()}-${Math.random().toString(16).slice(2)}`, categoryName: source.categoryName ?? "", revenue: source.revenue ?? "", revenueShare: source.revenueShare ?? "", cumulativeShare: source.cumulativeShare ?? "", inventoryCapital: source.inventoryCapital ?? "", capitalOccupancyRate: source.capitalOccupancyRate ?? "", sellThroughRate: source.sellThroughRate ?? "", grossMargin: source.grossMargin ?? "", abcClassification: source.abcClassification ?? "" }; }
function createStepFour(source = {}) { return { productName: source.productName ?? "", matrixQuadrant: source.matrixQuadrant ?? "", matrixReason: source.matrixReason ?? "", necessityAnalysis: source.necessityAnalysis ?? "", recommendedClassification: source.recommendedClassification ?? "", trafficSupportMeasures: source.trafficSupportMeasures ?? "" }; }
function createStrategyRows(savedRows = []) { const saved = new Map((Array.isArray(savedRows) ? savedRows : []).map((row) => [row?.layer, row])); return [{ layer: "A", label: "A类核心品类", operationPlaceholder: "例如:重点投入流量与推广预算,持续优化转化率", inventoryPlaceholder: "例如:安全库存上限,高频补货,优先保障供货稳定" }, { layer: "B", label: "B类潜力品类", operationPlaceholder: "例如开展AB测试针对潜力品类配置流量扶持", inventoryPlaceholder: "例如:密切关注运营效果,活动前适当备货" }, { layer: "C", label: "C类长尾品类", operationPlaceholder: "例如控制流量预算通过组合活动逐步清退滞销SKU", inventoryPlaceholder: "例如停止主动补货清退滞销SKU" }, { layer: "NEW", label: "新产品", operationPlaceholder: "例如:设置新品测试活动,重点推广运营", inventoryPlaceholder: "例如:提前备货,根据渠道测试情况重点备货" }].map((row) => ({ ...row, operationStrategy: saved.get(row.layer)?.operationStrategy ?? "", inventoryStrategy: saved.get(row.layer)?.inventoryStrategy ?? "", resourcePriority: saved.get(row.layer)?.resourcePriority ?? "" })); }
function normalizeActiveStep(value) { return !configuredSteps.value.length ? 1 : Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
function buildProgress() { return { version: 1, activeStep: activeStep.value, activeModule: activeModule.value, categoryRows: categoryRows.value.map(({ abcClassification, ...row }) => ({ ...row })), updatedAt: new Date().toISOString() }; }
function buildStepTwoProgress() { return { version: 1, rows: categoryRows.value.map((row) => ({ id: row.id, abcClassification: row.abcClassification || "" })), updatedAt: new Date().toISOString() }; }
function buildStepThreeProgress() { return { version: 1, imageUrl: paretoImage.value.url, imageName: paretoImage.value.name, updatedAt: new Date().toISOString() }; }
function buildStepFourProgress() { return { version: 1, ...stepFour.value, updatedAt: new Date().toISOString() }; }
function buildStepFiveProgress() { return { version: 1, rows: strategyRows.value.map(({ layer, operationStrategy, inventoryStrategy, resourcePriority }) => ({ layer, operationStrategy, inventoryStrategy, resourcePriority })), updatedAt: new Date().toISOString() }; }
function applyProgress(value) {
if (!value || typeof value !== "object") return;
activeStep.value = normalizeActiveStep(value.activeStep);
activeModule.value = Math.min(Math.max(Number(value.activeModule) || 0, 0), modules.length - 1);
categoryRows.value = Array.isArray(value.categoryRows) ? value.categoryRows.slice(0, MAX_ROWS).map(createRow) : [];
}
function applyStepTwoProgress(value) { const classifications = new Map((Array.isArray(value?.rows) ? value.rows : []).map((row) => [row?.id, row?.abcClassification || ""])); categoryRows.value.forEach((row) => { row.abcClassification = classifications.get(row.id) || ""; }); }
function applyStepThreeProgress(value) { paretoImage.value = { url: String(value?.imageUrl || ""), name: String(value?.imageName || "") }; }
function applyStepFourProgress(value) { stepFour.value = createStepFour(value); }
function applyStepFiveProgress(value) { strategyRows.value = createStrategyRows(value?.rows); }
async function persistProgress(saveAction = "SAVE") { await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()), step2Answer: JSON.stringify(buildStepTwoProgress()), step3Answer: JSON.stringify(buildStepThreeProgress()), step4Answer: JSON.stringify(buildStepFourProgress()), step5Answer: JSON.stringify(buildStepFiveProgress()) }); }
function addRow() { if (categoryRows.value.length >= MAX_ROWS) return; categoryRows.value.push(createRow()); }
function removeLastRow() { categoryRows.value.pop(); }
async function validateStepOne() {
if (!categoryRows.value.length) {
ElMessage.warning("请至少填写一行品类数据");
return false;
}
try {
const response = await validateCategoryOptimizationStepOne({ rows: categoryRows.value });
if (response?.data?.valid === false) throw new Error(response.data.message || "数据校验未通过");
return true;
} catch (error) {
// 请求层已经展示了后端 400 返回的业务提示,避免再叠加一条英文 Axios 错误。
if (error?.response?.status === 400) return false;
ElMessage.error(error?.message || error?.msg || "数据校验未通过,请检查填写内容");
return false;
}
}
async function validateStepTwo() {
try {
const rows = categoryRows.value.map((row) => ({ categoryName: row.categoryName, revenue: row.revenue, revenueShare: row.revenueShare, cumulativeShare: row.cumulativeShare, abcClassification: row.abcClassification }));
const response = await validateCategoryOptimizationStepTwo({ rows });
if (response?.data?.valid === false) throw new Error(response.data.message || "ABC分层校验未通过");
return true;
} catch (error) {
ElMessage.error(error?.message || error?.msg || "ABC分层校验未通过请检查填写内容");
return false;
}
}
async function validateStepThree() {
try {
const response = await validateCategoryOptimizationStepThree({ imageUrl: paretoImage.value.url });
if (response?.data?.valid === false) throw new Error(response.data.message || "帕累托图校验未通过");
return true;
} catch (error) {
ElMessage.error(error?.message || error?.msg || "帕累托图校验未通过,请上传图片");
return false;
}
}
async function handleParetoUpload(event) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
if (!file.type?.startsWith("image/")) { ElMessage.error("请上传图片格式的帕累托图"); return; }
uploadingPareto.value = true;
try {
let url = "";
if (isStudentDemo()) url = URL.createObjectURL(file);
else { const formData = new FormData(); formData.append("file", file); const response = await uploadStudentTrainingFile(formData); url = response?.url || response?.data?.url || ""; }
if (!url) throw new Error("帕累托图上传失败,请重试");
removeParetoImage();
paretoImage.value = { url, name: file.name };
ElMessage.success("帕累托图已上传,可点击预览");
} catch (error) { ElMessage.error(error?.message || "帕累托图上传失败,请重试"); } finally { uploadingPareto.value = false; }
}
function removeParetoImage() { if (paretoImage.value.url.startsWith("blob:")) URL.revokeObjectURL(paretoImage.value.url); paretoImage.value = { url: "", name: "" }; }
function handleProductChange() { if (stepFour.value.recommendedClassification === selectedProduct.value?.abcClassification) stepFour.value.recommendedClassification = ""; }
async function validateStepFour() {
try {
const response = await validateCategoryOptimizationStepFour({ ...stepFour.value, availableRows: categoryRows.value });
if (response?.data?.valid === false) throw new Error(response.data.message || "新品分层优化校验未通过");
return true;
} catch (error) { ElMessage.error(error?.message || error?.msg || "新品分层优化校验未通过,请检查填写内容"); return false; }
}
async function validateStepFive() {
try { const response = await validateCategoryOptimizationStepFive({ rows: strategyRows.value }); if (response?.data?.valid === false) throw new Error(response.data.message || "差异化运营策略校验未通过"); return true; } catch (error) { ElMessage.error(error?.message || error?.msg || "差异化运营策略校验未通过,请检查填写内容"); return false; }
}
async function setCurrentStep(step) {
const nextStep = normalizeActiveStep(step);
if (activeStep.value === 1 && nextStep > 1 && !(await validateStepOne())) return;
if ((activeStep.value === 2 && nextStep > 2 || activeStep.value === 1 && nextStep > 2) && !(await validateStepTwo())) return;
if ((activeStep.value === 3 && nextStep > 3 || activeStep.value === 2 && nextStep > 3 || activeStep.value === 1 && nextStep > 3) && !(await validateStepThree())) return;
if ((activeStep.value === 4 && nextStep > 4 || activeStep.value === 3 && nextStep > 4 || activeStep.value === 2 && nextStep > 4 || activeStep.value === 1 && nextStep > 4) && !(await validateStepFour())) return;
if ((activeStep.value === 5 && nextStep > 5 || activeStep.value === 4 && nextStep > 5 || activeStep.value === 3 && nextStep > 5 || activeStep.value === 2 && nextStep > 5 || activeStep.value === 1 && nextStep > 5) && !(await validateStepFive())) return;
activeStep.value = nextStep;
persistProgress("SAVE").catch(() => {});
}
function setActiveModule(index) { activeModule.value = index; persistProgress("SAVE").catch(() => {}); }
async function goNextStep() { await setCurrentStep(activeStep.value + 1); }
async function saveCurrentProgress() {
if (saving.value) return;
saving.value = true;
try { await persistProgress(); ElMessage.success("当前进度已保存"); } finally { saving.value = false; }
}
async function submitTask() {
if (saving.value || !(await validateStepOne()) || (hasConfiguredSteps.value && configuredSteps.value.length > 1 && !(await validateStepTwo())) || (hasConfiguredSteps.value && configuredSteps.value.length > 2 && !(await validateStepThree())) || (hasConfiguredSteps.value && configuredSteps.value.length > 3 && !(await validateStepFour())) || (hasConfiguredSteps.value && configuredSteps.value.length > 4 && !(await validateStepFive()))) return;
saving.value = true;
try { await businessComponent.value?.submitData?.(); await persistProgress("SAVE"); ElMessage.success("任务已提交"); } finally { saving.value = false; }
}
async function resetTask() {
if (saving.value) return;
categoryRows.value = [];
removeParetoImage();
stepFour.value = createStepFour();
strategyRows.value = createStrategyRows();
activeStep.value = 1;
activeModule.value = 0;
workspaceKey.value += 1;
saving.value = true;
try { await persistProgress("RESET"); ElMessage.success("填写内容已清空"); } finally { saving.value = false; }
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildProgress(), 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);
}
onMounted(async () => {
try { const res = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = res?.data || null; activeStep.value = normalizeActiveStep(activeStep.value); } catch (error) { taskConfig.value = null; }
try { const res = await getStudentTrainingAnswer(TASK_KEY); if (res?.data?.step1Answer) applyProgress(JSON.parse(res.data.step1Answer)); if (res?.data?.step2Answer) applyStepTwoProgress(JSON.parse(res.data.step2Answer)); if (res?.data?.step3Answer) applyStepThreeProgress(JSON.parse(res.data.step3Answer)); if (res?.data?.step4Answer) applyStepFourProgress(JSON.parse(res.data.step4Answer)); if (res?.data?.step5Answer) applyStepFiveProgress(JSON.parse(res.data.step5Answer)); } catch (error) {}
});
</script>
<style scoped lang="scss">
.optimization { --ink:#edfaff; --muted:#9ec0cf; --line:rgba(99,217,255,.27); --accent:#27c7f2; display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:24px; min-height:calc(100vh - 76px); padding:24px; color:var(--ink); background:radial-gradient(circle at 8% 0%,rgba(28,164,211,.18),transparent 32rem),linear-gradient(135deg,#061923,#082633 46%,#061923); }
.training-core { min-width:0; padding:28px; border:1px solid var(--line); border-radius:28px; background:rgba(5,25,37,.82); box-shadow:0 22px 48px rgba(0,0,0,.22); }.task-header{margin-bottom:20px}.task-badge{display:inline-flex;align-items:center;gap:8px;padding:5px 12px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.3);font-size:var(--student-fs-caption);font-weight:800}.task-header h1{margin:10px 0 0;font-size:var(--student-fs-display)}.task-card{margin-top:24px;overflow:hidden;border:1px solid var(--line);border-radius:20px;background:rgba(3,22,34,.78)}
.workbench-head{display:flex;justify-content:space-between;gap:18px;padding:22px 24px;border-bottom:1px solid var(--line)}.section-eyebrow{margin:0;color:#71dfff;font-size:var(--student-fs-caption);font-weight:800;letter-spacing:.12em}.workbench-head h2{margin:5px 0 0;font-size:var(--student-fs-h1)}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.workbench-actions{justify-content:flex-end}.outline-action,.action-button,.nav-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;padding:0 16px;border:1px solid rgba(99,217,255,.5);border-radius:9px;color:#e9fbff;background:rgba(17,126,178,.34);font:inherit;font-weight:700;cursor:pointer}.outline-action:hover,.action-button:hover,.nav-button:hover{border-color:#7deaff;background:rgba(20,148,193,.48)}button:disabled{cursor:not-allowed;opacity:.45}
.step-indicator{display:flex;gap:9px;padding:16px 24px;border-bottom:1px solid var(--line);overflow-x:auto}.step-tab{display:inline-flex;align-items:center;gap:8px;min-width:max-content;padding:8px 12px;border:1px solid var(--line);border-radius:999px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.step-tab span{display:grid;width:21px;height:21px;place-items:center;border-radius:50%;color:#bdefff;background:rgba(16,117,154,.35);font-size:var(--student-fs-caption);font-weight:800}.step-tab.active{border-color:#71dfff;color:white;background:rgba(12,114,159,.48)}.step-tab.complete{color:#d5fff8}.step-tab.complete span{background:#2ba995}
.category-step{padding:24px}.instruction{margin:0 0 20px;color:#d8edf5;font-size:var(--student-fs-body);line-height:1.75}.table-toolbar{display:flex;justify-content:space-between;gap:14px;margin-bottom:12px;color:var(--muted);font-size:var(--student-fs-body-sm)}.action-button--muted{border-color:rgba(158,192,207,.35);background:rgba(75,101,112,.25)}.category-table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}.category-table{width:100%;min-width:1260px;border-collapse:collapse}.category-table th,.category-table td{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);text-align:center}.category-table th{color:#e6f9ff;background:rgba(23,112,150,.42);font-size:var(--student-fs-body-sm);white-space:nowrap}.category-table td{background:rgba(2,18,28,.32)}.category-table tr:last-child td{border-bottom:0}.category-table th:last-child,.category-table td:last-child{border-right:0}.empty-cell{height:88px;color:var(--muted)}.abc-table{min-width:900px}.abc-table :deep(.el-select){width:100%;min-width:180px}
.rule-notice{margin-top:20px;padding:16px 18px;border:1px solid rgba(255,112,112,.46);border-radius:12px;background:rgba(126,31,35,.15);color:#ffd5d5}.rule-notice strong{color:#ff9292}.rule-notice p{margin:8px 0 0;line-height:1.65}.analysis-step{padding:24px}.analysis-tabs{display:flex;gap:10px;margin-bottom:18px}.analysis-tabs button{padding:9px 14px;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.analysis-tabs button.active{color:white;border-color:#71dfff;background:rgba(12,114,159,.48)}.business-workspace{overflow:hidden;border:1px solid var(--line);border-radius:14px}.step-navigation{justify-content:center;padding:20px 24px 0}.nav-button--save,.nav-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}.training-actions{justify-content:center;gap:28px;padding:26px 24px}.training-actions :deep(.training-action){min-width:160px;height:50px;border-color:#2c83aa;color:#dff7ff;background:rgba(13,64,88,.62);font-size:17px;font-weight:800}
.pareto-upload-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;min-height:330px;padding:26px;border:1px dashed rgba(113,223,255,.7);border-radius:16px;background:linear-gradient(135deg,rgba(5,47,69,.7),rgba(4,23,35,.58));text-align:center}.file-input{display:none}.upload-icon{font-size:42px;color:#71dfff}.pareto-upload-panel>strong{font-size:var(--student-fs-display)}.pareto-upload-panel>span,.upload-file-meta span{color:var(--muted);font-size:var(--student-fs-body-sm)}.pareto-preview{width:min(100%,760px);height:270px;border:1px solid var(--line);border-radius:10px;background:rgba(0,10,18,.65);cursor:zoom-in}.upload-file-meta{display:grid;gap:5px;max-width:100%;word-break:break-all}.pareto-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.danger-action{border-color:rgba(255,145,145,.6);color:#ffd7d7;background:rgba(130,43,43,.23)}
.strategy-step{display:grid;gap:16px}.subsection-heading{margin:8px 0 0;font-size:var(--student-fs-h1)}.subsection-copy{margin:-8px 0 0;color:var(--muted);line-height:1.6}.category-matrix{position:relative;width:min(100%,820px);height:430px;margin:12px auto 22px;padding:38px 52px 52px 78px}.matrix-grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:100%;height:100%;border-left:2px solid rgba(185,240,255,.82);border-bottom:2px solid rgba(185,240,255,.82);background:linear-gradient(135deg,rgba(24,169,209,.12),rgba(3,25,38,.16))}.matrix-cell{display:grid;place-content:center;gap:7px;padding:18px;border-right:1px solid rgba(185,240,255,.65);border-bottom:1px solid rgba(185,240,255,.65);text-align:center}.matrix-cell:nth-child(2n){border-right:0}.matrix-cell:nth-child(n+3){border-bottom:0}.matrix-cell strong{font-size:var(--student-fs-h1)}.matrix-cell small{color:#8dddf5}.matrix-y-title,.matrix-x-title,.matrix-y-high,.matrix-y-low,.matrix-x-high,.matrix-x-low{position:absolute;color:#d9f8ff;font-weight:800}.matrix-y-title{top:5px;left:78px}.matrix-x-title{right:0;bottom:15px}.matrix-y-high{top:63px;left:39px}.matrix-y-low{bottom:61px;left:39px}.matrix-x-low{bottom:29px;left:79px}.matrix-x-high{right:44px;bottom:29px}.matrix-inputs{display:grid;grid-template-columns:minmax(210px,.42fr) 1fr;gap:16px}.matrix-inputs label{display:grid;gap:8px;color:#d9f8ff;font-weight:700}.strategy-table{min-width:1120px}.strategy-table td{vertical-align:top}.strategy-table :deep(.el-select){width:100%;min-width:160px}.strategy-table :deep(.el-textarea__inner){min-height:78px}.current-classification{display:inline-block;padding:8px 10px;border-radius:8px;color:#a5efff;background:rgba(17,126,178,.24);white-space:nowrap}
.operations-table{min-width:1050px}.operations-table th{color:#fff;background:linear-gradient(100deg,#08a6df,#098bc1);font-size:var(--student-fs-h2)}.operations-table td{vertical-align:middle}.operations-table td:first-child{width:17%;font-weight:800}.operations-table td:nth-child(2){width:34%}.operations-table td:nth-child(3){width:25%}.operations-table td:last-child{width:24%}.operations-table :deep(.el-textarea__inner){min-height:86px;text-align:left}.operations-table :deep(.el-select){width:100%;min-width:160px}.layer-cell{color:#dff8ff;font-size:var(--student-fs-h2);background:rgba(15,103,137,.18)!important}
:deep(.el-input__wrapper){background:rgba(1,15,25,.76);box-shadow:0 0 0 1px rgba(94,213,245,.28) inset}:deep(.el-input__inner){color:#fff;text-align:center}@media(max-width:1100px){.optimization{grid-template-columns:1fr}}@media(max-width:720px){.optimization{padding:12px}.training-core{padding:16px;border-radius:20px}.workbench-head,.table-toolbar{flex-direction:column;align-items:stretch}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{justify-content:stretch}.workbench-actions>*,.action-button,.nav-button,.training-actions :deep(.training-action){flex:1;width:100%}.category-step,.analysis-step{padding:16px}}
</style>