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

1220 lines
52 KiB
Vue

4 months ago
<template>
<div class="optimization-page student-training-shell">
<main class="optimization-core">
<section class="task-header">
<div class="task-head-main">
<span class="task-badge"><el-icon><TrendCharts /></el-icon>核心实训 · 产品上线与运营推广</span>
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
</section>
<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"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
<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>
<span v-if="index < configuredSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</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-number v-model="row.capitalOccupancyRate" :min="0" :max="100" :precision="2" :step="0.01" :controls="false" inputmode="decimal" :aria-label="`第${index + 1}行资金占用率(百分数)`" /></td>
<td><el-input-number v-model="row.sellThroughRate" :min="0" :max="100" :precision="2" :step="0.01" :controls="false" 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>
<section class="pareto-materials" aria-label="帕累托图参考资料">
<div>
<h3>参考方法及工具如下:</h3>
<ol>
<li>下载案例数据表,使用 Excel 计算各品类占比、累计占比并进行 ABC 分层判定,绘制帕累托图。</li>
<li>下载 Excel 模板,填写关键数据,完成帕累托图绘制。</li>
<li>也可导出数据表并使用通用 AI 工具辅助完成计算与可视化。</li>
</ol>
</div>
<div class="pareto-materials__actions">
<button type="button" class="action-button" @click="downloadParetoGuide">下载实训操作说明</button>
<button type="button" class="action-button action-button--primary" @click="downloadParetoTemplate">下载 Excel 模板</button>
</div>
</section>
<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" />
4 months ago
</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 { 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 PARETO_GUIDE_URL = "/file/training-materials/category-optimization/20.实训操作说明.docx";
const PARETO_TEMPLATE_URL = "/file/training-materials/category-optimization/20.帕累托绘制自动化模版.xlsx";
const MAX_ROWS = 30;
const briefOpen = ref(false);
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 dynamicStepAnswers = ref({});
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 normalizePercentageValue(value) {
const text = String(value ?? "").trim().replace(/%/g, "");
if (!text) return null;
const number = Number(text);
return Number.isFinite(number) ? Number(number.toFixed(2)) : null;
}
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: normalizePercentageValue(source.capitalOccupancyRate), sellThroughRate: normalizePercentageValue(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 parseSavedJson(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; return parsed && typeof parsed === "object" ? parsed : null; } catch (error) { return null; } }
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") { dynamicStepAnswers.value = { ...dynamicStepAnswers.value, step5: buildStepFiveProgress() }; await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, submitted: saveAction === "SUBMIT", step1Answer: JSON.stringify(buildProgress()), step2Answer: JSON.stringify(buildStepTwoProgress()), step3Answer: JSON.stringify(buildStepThreeProgress()), step4Answer: JSON.stringify(buildStepFourProgress()), dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value) }); }
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 downloadParetoGuide() { downloadBuiltInFile(PARETO_GUIDE_URL, "20.实训操作说明.docx"); }
function downloadParetoTemplate() { downloadBuiltInFile(PARETO_TEMPLATE_URL, "20.帕累托绘制自动化模版.xlsx"); }
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
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("SUBMIT"); 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)); dynamicStepAnswers.value = parseSavedJson(res?.data?.dynamicStepAnswers) || {}; if (dynamicStepAnswers.value.step5) applyStepFiveProgress(dynamicStepAnswers.value.step5); } catch (error) {}
});
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(墨黑极简主题) ===== */
.optimization-page {
--student-accent: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
.optimization-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header ===== */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
text-transform: none;
letter-spacing: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
/* ===== task-card(透明化) ===== */
.task-card {
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* ===== workbench-head ===== */
.workbench-head {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 0;
border-bottom: 0;
}
.section-eyebrow {
margin: 0;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workbench-head h2 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.workbench-actions,
.table-toolbar > div {
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(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease,
color 0.16s ease, transform 0.16s ease,
box-shadow 0.16s ease;
}
.outline-action:hover:not(:disabled),
.action-button:hover:not(:disabled),
.nav-button:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.outline-action:disabled,
.action-button:disabled,
.nav-button:disabled,
button:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
/* 主按钮(墨黑实底) */
.action-button--primary,
.nav-button--primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.action-button--primary:hover:not(:disabled),
.nav-button--primary:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
/* 保存按钮(浅灰底) */
.nav-button--save {
border-color: #e5e7eb;
color: #374151;
background: var(--student-accent-soft);
box-shadow: none;
}
.nav-button--save:hover:not(:disabled) {
border-color: #d1d5db;
background: #e9ecef;
color: #1f2937;
box-shadow: none;
transform: none;
}
.action-button--muted {
border-color: #e5e7eb;
background: var(--student-accent-soft);
color: var(--student-text-secondary);
}
/* 危险按钮 */
.danger-action {
border-color: rgba(217, 85, 76, 0.4);
color: var(--student-danger);
background: #ffffff;
}
/* ===== 步骤容器透明化 ===== */
.category-step,
.analysis-step {
padding: 0;
}
/* ===== 引导区 / instruction ===== */
.instruction {
margin: 0 0 14px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
background: var(--student-accent-soft);
}
/* ===== 表格 ===== */
.table-toolbar {
display: flex;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.category-table-wrap,
.business-workspace {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
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(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
text-align: center;
}
.category-table th {
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
white-space: nowrap;
}
.category-table td {
background: transparent;
}
.category-table tr:last-child td { border-bottom: 0; }
.category-table th:last-child,
.category-table td:last-child { border-right: 0; }
.category-table :deep(.el-input-number) { width: 100%; }
.empty-cell {
height: 88px;
color: var(--student-text-secondary);
}
.abc-table { min-width: 900px; }
.abc-table :deep(.el-select) { width: 100%; min-width: 180px; }
/* rule-notice 保留红色语义 */
.rule-notice {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid rgba(217, 85, 76, 0.36);
border-radius: 10px;
background: rgba(217, 85, 76, 0.06);
color: #991b1b;
}
.rule-notice strong { color: #991b1b; }
.rule-notice p { margin: 6px 0 0; line-height: 1.6; }
/* ===== 分析 step(tabs) ===== */
.analysis-tabs {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.analysis-tabs button {
padding: 8px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text-secondary);
background: #ffffff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
}
.analysis-tabs button:hover {
border-color: var(--student-accent);
color: var(--student-accent);
}
.analysis-tabs button.active {
color: #ffffff;
border-color: var(--student-accent);
background: var(--student-accent);
}
/* ===== 底部步骤导航 ===== */
.step-navigation {
display: flex;
justify-content: center;
gap: 14px;
padding: 0;
margin-top: 20px;
}
/* ===== 底部提交按钮 ===== */
.training-actions {
display: flex;
justify-content: center;
gap: 28px;
padding: 0;
margin-top: 14px;
}
.training-actions :deep(.training-action) {
min-width: 160px;
height: 40px;
border-radius: 10px;
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-size: var(--student-fs-button);
font-weight: 700;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.training-actions :deep(.training-action:hover) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
/* ===== Pareto 上传 ===== */
.pareto-upload-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 330px;
padding: 26px;
border: 1px dashed var(--student-panel-border);
border-radius: 16px;
background: #fafbfc;
text-align: center;
}
.file-input { display: none; }
.upload-icon {
font-size: 42px;
color: var(--student-accent-light);
}
.pareto-upload-panel > strong {
font-size: var(--student-fs-h1);
color: var(--student-text);
}
.pareto-upload-panel > span,
.upload-file-meta span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
}
.pareto-preview {
width: min(100%, 760px);
height: 270px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
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;
}
/* ===== Pareto 参考资料引导区 ===== */
.pareto-materials {
display: flex;
align-items: center;
justify-content: space-between;
gap: 22px;
margin-top: 14px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
background: var(--student-accent-soft);
color: var(--student-text-secondary);
}
.pareto-materials h3 {
margin: 0 0 6px;
font-size: var(--student-fs-h2);
color: var(--student-text);
}
.pareto-materials ol {
margin: 0;
padding-left: 20px;
line-height: 1.7;
font-size: var(--student-fs-body);
}
.pareto-materials ol li + li { margin-top: 2px; }
.pareto-materials__actions {
display: grid;
flex: 0 0 178px;
gap: 10px;
}
/* ===== 策略 step ===== */
.strategy-step {
display: grid;
gap: 12px;
}
.subsection-heading {
margin: 8px 0 0;
font-size: var(--student-fs-h1);
color: var(--student-text);
}
.subsection-copy {
margin: -8px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
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 var(--student-panel-border);
border-bottom: 2px solid var(--student-panel-border);
background: #fafbfc;
}
.matrix-cell {
display: grid;
place-content: center;
gap: 7px;
padding: 18px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
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-h2);
color: var(--student-text);
}
.matrix-cell small { color: var(--student-text-secondary); }
.matrix-y-title,
.matrix-x-title,
.matrix-y-high,
.matrix-y-low,
.matrix-x-high,
.matrix-x-low {
position: absolute;
color: var(--student-text);
font-weight: 700;
}
.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; }
/* ===== 矩阵 inputs ===== */
.matrix-inputs {
display: grid;
grid-template-columns: minmax(210px, 0.42fr) 1fr;
gap: 14px;
}
.matrix-inputs label {
display: grid;
gap: 6px;
color: var(--student-text);
font-size: var(--student-fs-body-sm);
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: 6px 10px;
border-radius: 6px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
white-space: nowrap;
font-weight: 700;
font-size: var(--student-fs-body-sm);
}
/* ===== 运营策略表格 ===== */
.operations-table { min-width: 1050px; }
.operations-table th {
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
}
.operations-table td {
vertical-align: middle;
color: var(--student-text);
}
.operations-table td:first-child { width: 17%; font-weight: 700; }
.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: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
background: var(--student-accent-soft) !important;
}
/* ===== Element Plus 输入框换肤 ===== */
:deep(.el-input__wrapper) {
background: #ffffff;
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 2px rgba(31, 41, 55, 0.2) inset;
}
:deep(.el-input__inner) {
color: var(--student-text);
}
/* ===== 响应式 ===== */
@media (max-width: 720px) {
.task-header { flex-direction: column; align-items: flex-start; }
.workbench-head,
.table-toolbar,
.pareto-materials {
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%;
}
.pareto-materials__actions {
flex: 0 0 auto;
width: 100%;
}
.matrix-inputs {
grid-template-columns: 1fr;
}
}
</style>
<style lang="scss">
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.optimization-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow: visible !important;
.step-connector { display: none !important; }
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
/* 编号小徽章(替换旧圆形大圈) */
span {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
/* 未到达项 hover */
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
span { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成 */
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
span { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
span { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤 */
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
span { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏:900px 以下横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.optimization-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.optimization-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
</style>