|
|
<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>
|