|
|
<template>
|
|
|
<div class="optimization-page student-training-shell">
|
|
|
<main class="optimization-core">
|
|
|
<section class="task-header">
|
|
|
<div class="task-head-main">
|
|
|
<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-action-strip">
|
|
|
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTask"
|
|
|
:on-export="exportOutcome" />
|
|
|
</section>
|
|
|
|
|
|
<section class="task-card">
|
|
|
|
|
|
<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">
|
|
|
<header v-if="configuredSteps[activeStep - 1]" class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ configuredSteps[activeStep - 1] }}</h2>
|
|
|
</header>
|
|
|
<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">
|
|
|
<header v-if="configuredSteps[activeStep - 1]" class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ configuredSteps[activeStep - 1] }}</h2>
|
|
|
</header>
|
|
|
<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">
|
|
|
<header v-if="configuredSteps[activeStep - 1]" class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ configuredSteps[activeStep - 1] }}</h2>
|
|
|
</header>
|
|
|
<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">
|
|
|
<header v-if="configuredSteps[activeStep - 1]" class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ configuredSteps[activeStep - 1] }}</h2>
|
|
|
</header>
|
|
|
<p class="instruction">作为战略新品,一般营收贡献较低,被归入C类长尾品类。但新品具有技术领先性和市场增长潜力,公司需对其进行流量扶持,需要进行新品分层优化调整。</p>
|
|
|
<div class="section-heading"><span>01</span>
|
|
|
<div>
|
|
|
<h3>品类矩阵定位</h3>
|
|
|
<p>新品战略定位,根据上表,确定新产品应该归为上述矩阵的那个位置,说明原因。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="category-matrix" aria-label="市场增长率与客群重叠度矩阵">
|
|
|
<span class="matrix-y-title">市场增长率</span>
|
|
|
<div class="matrix-plot">
|
|
|
<span class="matrix-axis-y" aria-hidden="true"></span>
|
|
|
<span class="matrix-axis-x" aria-hidden="true"></span>
|
|
|
<span class="matrix-y-high">高</span><span class="matrix-y-low">低</span><span
|
|
|
class="matrix-x-low">低</span><span class="matrix-x-high">高</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>
|
|
|
<span class="matrix-x-title">客群重叠度</span>
|
|
|
</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>
|
|
|
|
|
|
<div class="section-heading"><span>02</span>
|
|
|
<div>
|
|
|
<h3>新品分层优化</h3>
|
|
|
<p>结合品类分析结果,提出新品分层优化建议。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<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">
|
|
|
<header v-if="configuredSteps[activeStep - 1]" class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ configuredSteps[activeStep - 1] }}</h2>
|
|
|
</header>
|
|
|
<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">
|
|
|
<div class="step-nav-left">
|
|
|
<button v-if="activeStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
|
|
|
@click="previousStep">
|
|
|
<el-icon>
|
|
|
<ArrowLeft />
|
|
|
</el-icon>
|
|
|
上一步
|
|
|
</button>
|
|
|
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
|
|
|
<el-icon>
|
|
|
<CircleCheck />
|
|
|
</el-icon>
|
|
|
保存
|
|
|
</button>
|
|
|
</div>
|
|
|
<div class="step-nav-right">
|
|
|
<span class="step-progress">Step {{ String(activeStep).padStart(2, "0") }} / {{
|
|
|
String(configuredSteps.length).padStart(2, "0") }}</span>
|
|
|
<button v-if="activeStep < configuredSteps.length" type="button" class="btn-nav btn-primary"
|
|
|
:disabled="saving" @click="goNextStep">
|
|
|
下一步
|
|
|
<el-icon>
|
|
|
<ArrowRight />
|
|
|
</el-icon>
|
|
|
</button>
|
|
|
</div>
|
|
|
</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, Minus, Plus, TrendCharts, UploadFilled } from "@element-plus/icons-vue"
|
|
|
import { ElMessage } from "element-plus"
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
|
|
|
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask"
|
|
|
import { deleteStudentTrainingAnswer, 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 briefOpen = ref(true)
|
|
|
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 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) {
|
|
|
if (saving.value) return
|
|
|
saving.value = true
|
|
|
try {
|
|
|
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(() => { })
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
function setActiveModule(index) { activeModule.value = index; persistProgress("SAVE").catch(() => { }) }
|
|
|
function previousStep() { if (activeStep.value > 1) setCurrentStep(activeStep.value - 1) }
|
|
|
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
|
|
|
saving.value = true
|
|
|
try {
|
|
|
await deleteStudentTrainingAnswer(TASK_KEY)
|
|
|
categoryRows.value = []
|
|
|
removeParetoImage()
|
|
|
stepFour.value = createStepFour()
|
|
|
strategyRows.value = createStrategyRows()
|
|
|
activeStep.value = 1
|
|
|
activeModule.value = 0
|
|
|
workspaceKey.value += 1
|
|
|
ElMessage.success("已清空全部实训步骤,可以重新开始")
|
|
|
} catch (error) {
|
|
|
ElMessage.error("重新实训失败,请稍后重试")
|
|
|
} 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: #2563eb;
|
|
|
--student-accent-deep: #1d4ed8;
|
|
|
--student-accent-light: #3b82f6;
|
|
|
--student-accent-soft: #eff6ff;
|
|
|
--student-accent-border: #bfdbfe;
|
|
|
--student-shadow-color: rgba(15, 23, 42, 0.08);
|
|
|
--student-danger: #ef4444;
|
|
|
--student-success: #2ba471;
|
|
|
--student-layer-1: #1d4ed8;
|
|
|
--student-layer-2: #3b82f6;
|
|
|
--student-layer-3: #93c5fd;
|
|
|
--student-panel-border: #e5e7eb;
|
|
|
--student-card-bg: #ffffff;
|
|
|
--student-panel-bg: #fafbfc;
|
|
|
--student-text: #1f2937;
|
|
|
--student-text-secondary: #6b7280;
|
|
|
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
|
|
|
|
|
|
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-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: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.task-brief-text {
|
|
|
display: block;
|
|
|
}
|
|
|
|
|
|
.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: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
ul {
|
|
|
display: grid;
|
|
|
gap: 4px;
|
|
|
margin: 0;
|
|
|
padding-left: 18px;
|
|
|
}
|
|
|
|
|
|
p {
|
|
|
margin: 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== task-action-strip(操作工具栏) ===== */
|
|
|
.task-action-strip {
|
|
|
margin: 0;
|
|
|
|
|
|
:deep(.training-task-toolbar) {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: flex-end;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== task-card(透明化) ===== */
|
|
|
.task-card {
|
|
|
overflow: visible;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
/* ===== 按钮规范(白底灰边 / 墨黑实底 / 浅灰底) ===== */
|
|
|
.outline-action,
|
|
|
.action-button {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 7px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
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) {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
color: var(--student-accent);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
|
|
|
.outline-action:disabled,
|
|
|
.action-button:disabled,
|
|
|
button:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
/* 主按钮(墨黑实底) */
|
|
|
.action-button--primary {
|
|
|
border-color: var(--student-accent);
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
|
|
|
}
|
|
|
|
|
|
.action-button--primary:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent-deep);
|
|
|
background: var(--student-accent-deep);
|
|
|
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
}
|
|
|
|
|
|
/* ===== 步骤标题块(与其他实训页统一) ===== */
|
|
|
.step-heading {
|
|
|
display: grid;
|
|
|
gap: 4px;
|
|
|
margin: 0 0 10px;
|
|
|
}
|
|
|
|
|
|
.step-heading .step-eyebrow {
|
|
|
margin: 0;
|
|
|
color: var(--student-accent);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 800;
|
|
|
letter-spacing: 0.12em;
|
|
|
text-transform: uppercase;
|
|
|
}
|
|
|
|
|
|
.step-heading h2 {
|
|
|
margin: 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h2);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.4;
|
|
|
}
|
|
|
|
|
|
/* ===== 引导区 / 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 {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
gap: 10px;
|
|
|
margin-top: 16px;
|
|
|
padding: 12px 16px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
flex-wrap: nowrap;
|
|
|
}
|
|
|
|
|
|
.step-nav-right {
|
|
|
margin-left: auto;
|
|
|
}
|
|
|
|
|
|
.btn-primary {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid var(--student-accent);
|
|
|
border-radius: 8px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent-deep);
|
|
|
background: var(--student-accent-deep);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
|
|
|
}
|
|
|
|
|
|
&:active:not(:disabled) {
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-ghost {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: var(--student-accent);
|
|
|
background: #ffffff;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-save {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
border-radius: 8px;
|
|
|
color: #4b5563;
|
|
|
background: #f9fafb;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: #d1d5db;
|
|
|
background: #f3f4f6;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-primary:disabled,
|
|
|
.btn-ghost:disabled,
|
|
|
.btn-save:disabled,
|
|
|
.btn-nav:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.step-progress {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-family: var(--student-font-data);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.05em;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
/* ===== 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;
|
|
|
}
|
|
|
|
|
|
|
|
|
/* ===== 策略 step ===== */
|
|
|
.strategy-step {
|
|
|
display: grid;
|
|
|
gap: 12px;
|
|
|
}
|
|
|
|
|
|
|
|
|
/* ===== 品类矩阵(坐标轴从原点出发,低→高) ===== */
|
|
|
.category-matrix {
|
|
|
position: relative;
|
|
|
width: min(100%, 820px);
|
|
|
margin: 30px auto 58px;
|
|
|
padding: 0 34px 40px 58px;
|
|
|
}
|
|
|
|
|
|
.matrix-y-title {
|
|
|
position: absolute;
|
|
|
top: -32px;
|
|
|
left: 58px;
|
|
|
color: var(--student-text);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.matrix-plot {
|
|
|
position: relative;
|
|
|
height: 380px;
|
|
|
}
|
|
|
|
|
|
.matrix-axis-y,
|
|
|
.matrix-axis-x {
|
|
|
position: absolute;
|
|
|
background: var(--student-text);
|
|
|
}
|
|
|
|
|
|
.matrix-axis-y {
|
|
|
left: 0;
|
|
|
top: 0;
|
|
|
bottom: 0;
|
|
|
width: 2px;
|
|
|
}
|
|
|
|
|
|
.matrix-axis-y::before {
|
|
|
content: "";
|
|
|
position: absolute;
|
|
|
top: -2px;
|
|
|
left: -5px;
|
|
|
border: 6px solid transparent;
|
|
|
border-bottom: 11px solid var(--student-text);
|
|
|
}
|
|
|
|
|
|
.matrix-axis-x {
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
bottom: 0;
|
|
|
height: 2px;
|
|
|
}
|
|
|
|
|
|
.matrix-axis-x::after {
|
|
|
content: "";
|
|
|
position: absolute;
|
|
|
right: -2px;
|
|
|
top: -5px;
|
|
|
border: 6px solid transparent;
|
|
|
border-left: 11px solid var(--student-text);
|
|
|
}
|
|
|
|
|
|
.matrix-y-high,
|
|
|
.matrix-y-low,
|
|
|
.matrix-x-high,
|
|
|
.matrix-x-low,
|
|
|
.matrix-x-title {
|
|
|
position: absolute;
|
|
|
color: var(--student-text);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.matrix-y-high {
|
|
|
top: -4px;
|
|
|
left: -34px;
|
|
|
}
|
|
|
|
|
|
.matrix-y-low {
|
|
|
bottom: -6px;
|
|
|
left: -34px;
|
|
|
}
|
|
|
|
|
|
.matrix-x-low {
|
|
|
left: 4px;
|
|
|
bottom: -30px;
|
|
|
}
|
|
|
|
|
|
.matrix-x-high {
|
|
|
right: 4px;
|
|
|
bottom: -30px;
|
|
|
}
|
|
|
|
|
|
.matrix-x-title {
|
|
|
right: -20px;
|
|
|
bottom: -30px;
|
|
|
}
|
|
|
|
|
|
.matrix-grid {
|
|
|
position: absolute;
|
|
|
inset: 0;
|
|
|
display: grid;
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
grid-template-rows: 1fr 1fr;
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
|
|
|
/* ===== 矩阵 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(37, 99, 235, 0.2) inset;
|
|
|
}
|
|
|
|
|
|
:deep(.el-input__inner) {
|
|
|
color: var(--student-text);
|
|
|
}
|
|
|
|
|
|
/* ===== 响应式 ===== */
|
|
|
@media (max-width: 720px) {
|
|
|
.task-header {
|
|
|
flex-direction: column;
|
|
|
align-items: flex-start;
|
|
|
}
|
|
|
|
|
|
.table-toolbar {
|
|
|
flex-direction: column;
|
|
|
align-items: stretch;
|
|
|
}
|
|
|
|
|
|
.table-toolbar>div {
|
|
|
justify-content: stretch;
|
|
|
}
|
|
|
|
|
|
.action-button {
|
|
|
flex: 1;
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.step-navigation {
|
|
|
flex-direction: column;
|
|
|
align-items: stretch;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.btn-nav {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
|
|
|
.matrix-inputs {
|
|
|
grid-template-columns: 1fr;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 小节标题(蓝底序号,与 diagnosis 页统一) ===== */
|
|
|
.section-heading {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
gap: 12px;
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
>span {
|
|
|
display: grid;
|
|
|
flex: 0 0 28px;
|
|
|
width: 28px;
|
|
|
height: 28px;
|
|
|
place-items: center;
|
|
|
border-radius: 6px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent, #2563eb);
|
|
|
font-size: var(--student-fs-body, 14px);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
h3 {
|
|
|
margin: 0;
|
|
|
color: var(--student-text, #1f2937);
|
|
|
font-size: var(--student-fs-h1, 18px);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
p {
|
|
|
margin: 4px 0 0;
|
|
|
color: var(--student-text-secondary, #6b7280);
|
|
|
font-size: var(--student-fs-body, 14px);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
}
|
|
|
</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: 0 !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 {
|
|
|
|
|
|
min-width: 0 !important;
|
|
|
|
|
|
display: flex !important;
|
|
|
flex-direction: row !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 8px !important;
|
|
|
height: auto !important;
|
|
|
min-height: 40px !important;
|
|
|
margin: 0 !important;
|
|
|
padding: 8px 14px !important;
|
|
|
border: 1px solid #e5e7eb !important;
|
|
|
border-right: none !important;
|
|
|
border-radius: 0 !important;
|
|
|
|
|
|
&:first-child {
|
|
|
border-radius: 8px 0 0 8px !important;
|
|
|
}
|
|
|
|
|
|
&:last-child {
|
|
|
border-radius: 0 8px 8px 0 !important;
|
|
|
border-right: 1px solid #e5e7eb !important;
|
|
|
}
|
|
|
|
|
|
background: #ffffff !important;
|
|
|
box-shadow: none !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-size: var(--student-fs-body) !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: var(--student-fs-body-sm) !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: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
color: #2563eb !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
span {
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* 已完成:绿色编号徽章 */
|
|
|
&.complete:not(.active) {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #374151 !important;
|
|
|
|
|
|
span {
|
|
|
background: rgba(43, 164, 113, 0.12) !important;
|
|
|
color: #16915f !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2ba471 !important;
|
|
|
background: #f0fdf6 !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
span {
|
|
|
background: #2ba471 !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* 当前步骤:教育蓝实底 */
|
|
|
&.active {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
font-weight: 700 !important;
|
|
|
box-shadow: none !important;
|
|
|
z-index: 2 !important;
|
|
|
|
|
|
span {
|
|
|
background: #ffffff !important;
|
|
|
color: #2563eb !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
background: #1d4ed8 !important;
|
|
|
border-color: #1d4ed8 !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 窄屏:900px 以下横向滑动 ===== */
|
|
|
@media (max-width: 900px) {
|
|
|
#app .studentLayout section.app-main:has(.optimization-page) .step-indicator {
|
|
|
justify-content: flex-start !important;
|
|
|
overflow-x: auto !important;
|
|
|
}
|
|
|
|
|
|
#app .studentLayout section.app-main:has(.optimization-page) .step-indicator .step-tab {
|
|
|
flex: 0 0 140px !important;
|
|
|
max-width: none !important;
|
|
|
}
|
|
|
}
|
|
|
</style>
|