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

1626 lines
64 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="optimization-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>