|
|
<template>
|
|
|
<div class="supplier-page student-training-shell" v-loading="loading">
|
|
|
<main class="supplier-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="saveStepThree" />
|
|
|
</section>
|
|
|
|
|
|
<section class="supplier-workbench">
|
|
|
<nav class="step-indicator" aria-label="供应渠道评估步骤">
|
|
|
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
|
|
|
<button type="button"
|
|
|
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
|
|
|
:aria-current="currentStep === index + 1 ? 'step' : undefined"
|
|
|
@click="goToStepFromTab(index + 1)">
|
|
|
<span class="step-marker">{{ String(index + 1).padStart(2, "0") }}</span><span
|
|
|
class="step-copy"><strong class="step-name">{{ step }}</strong></span>
|
|
|
</button>
|
|
|
</template>
|
|
|
</nav>
|
|
|
|
|
|
<template v-if="currentStep === 1">
|
|
|
<section class="supplier-step-card">
|
|
|
<section class="intro-card">
|
|
|
<p class="step-caption">Step 01</p>
|
|
|
<h2>设置两种采购场景的评估权重</h2>
|
|
|
<p>量化评估供应渠道一般包括采购成本、交货周期、品质可靠性、采购灵活性、服务支持等5个维度,且需要考虑小批量试产(100台)和大规模量产(10000台)两种不同的场景。请分别设置小批量试产(100台)和大规模量产(10000台)两个场景的权重。不同采购场景下各维度重要性不同,试产阶段更看重采购灵活性和交货周期,量产阶段更看重采购成本和品质可靠性。请根据不同场景,参考案例资料,为评估模型设置权重。</p>
|
|
|
</section>
|
|
|
|
|
|
<section class="weights-grid">
|
|
|
<article class="weight-card">
|
|
|
<table class="training-table merged-weight-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th style="width: 16%">评估维度</th>
|
|
|
<th>维度说明</th>
|
|
|
<th style="width: 21%">权重占比(%)- 小批量试产</th>
|
|
|
<th style="width: 21%">权重占比(%)- 大批量量产</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="row in rows" :key="row.key">
|
|
|
<td>{{ row.name }}</td>
|
|
|
<td>{{ row.description }}</td>
|
|
|
<td><el-input-number v-model="row.smallBatchWeight" :min="0" :max="100"
|
|
|
:precision="0" controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.largeBatchWeight" :min="0" :max="100"
|
|
|
:precision="0" controls-position="right" /></td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
<tfoot>
|
|
|
<tr>
|
|
|
<th colspan="2">权重合计(每个场景须为 100%)</th>
|
|
|
<td :class="{ danger: smallWeightTotal !== 100 }">{{ smallWeightTotal }}%</td>
|
|
|
<td :class="{ danger: largeWeightTotal !== 100 }">{{ largeWeightTotal }}%</td>
|
|
|
</tr>
|
|
|
</tfoot>
|
|
|
</table>
|
|
|
</article>
|
|
|
</section>
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="currentStep === 2">
|
|
|
<section class="supplier-step-card">
|
|
|
<section class="intro-card compact">
|
|
|
<p class="step-caption">Step 02</p>
|
|
|
<h2>为供应渠道评分并比较结果</h2>
|
|
|
<p>根据案例资料。按照统一的标准,从采购成本、交货周期、品质可靠性、采购灵活性、服务支持等5个维度进行赋分,每项分数为0-10分,计算试产及量产两种场景下的加权得分,填写下表。</p>
|
|
|
</section>
|
|
|
|
|
|
<section class="score-section">
|
|
|
<div class="section-heading"><span>01</span>
|
|
|
<div>
|
|
|
<h3>供应渠道评分与加权对比</h3>
|
|
|
<p>试产加权总分 = Σ(评分 × 试产权重) · 量产加权总分 = Σ(评分 × 量产权重)</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="table-scroll">
|
|
|
<table class="training-table score-table merged-score-table">
|
|
|
<thead>
|
|
|
<tr class="group-header">
|
|
|
<th rowspan="2" class="col-channel">渠道</th>
|
|
|
<th :colspan="rows.length" class="group-dimensions">维度评分(0–10 分)</th>
|
|
|
<th class="group-total group-total-small">试产加权总分</th>
|
|
|
<th class="group-total group-total-large">量产加权总分</th>
|
|
|
</tr>
|
|
|
<tr>
|
|
|
<th v-for="dimension in rows" :key="dimension.key" class="col-dimension">{{
|
|
|
dimension.name }}</th>
|
|
|
<th class="col-total col-total-small">Σ × 试产权重</th>
|
|
|
<th class="col-total col-total-large">Σ × 量产权重</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="row in channelRows" :key="row.id">
|
|
|
<td class="col-channel"><el-input v-model.trim="row.channel" maxlength="40"
|
|
|
placeholder="填写渠道名称" /></td>
|
|
|
<td v-for="dimension in rows" :key="dimension.key" class="col-dimension">
|
|
|
<el-input-number v-model="scoreFor(row, dimension.key).score" :min="0"
|
|
|
:max="10" :precision="0" controls-position="right" />
|
|
|
</td>
|
|
|
<td class="total-score col-total col-total-small">{{ weightedScore(row,
|
|
|
'smallBatch') }}</td>
|
|
|
<td class="total-score col-total col-total-large">{{ weightedScore(row,
|
|
|
'largeBatch') }}</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
</section>
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
<section class="supplier-step-card">
|
|
|
<section class="intro-card compact">
|
|
|
<p class="step-caption">Step 03</p>
|
|
|
<h2>渠道优选、采购建议与供应风险</h2>
|
|
|
<p>分析试产和量产两种场景下的最优渠道,评估主要供应风险并制定应对措施,形成综合采购建议。</p>
|
|
|
</section>
|
|
|
|
|
|
<section class="analysis-panel">
|
|
|
<div class="section-heading"><span>01</span>
|
|
|
<div>
|
|
|
<h3>渠道优选与场景分析</h3>
|
|
|
<p>根据评分结果,对比分析不同场景下最优渠道及次优,给出综合采购建议。排名结果为只读数据。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="table-scroll">
|
|
|
<table class="training-table ranking-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th>采购场景</th>
|
|
|
<th>最优渠道(排名1)</th>
|
|
|
<th>次优渠道(排名2)</th>
|
|
|
<th>分差与分析</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="analysis in scenarioAnalyses" :key="analysis.scenario">
|
|
|
<td class="scene-name">{{ analysis.label }}</td>
|
|
|
<td>
|
|
|
<div class="rank-result"><strong>{{ rankAt(analysis.scenario, 0).channel ||
|
|
|
'--' }}</strong><span>{{ formatScore(rankAt(analysis.scenario,
|
|
|
0).score) }} 分</span></div>
|
|
|
</td>
|
|
|
<td>
|
|
|
<div class="rank-result"><strong>{{ rankAt(analysis.scenario, 1).channel ||
|
|
|
'--' }}</strong><span>{{ formatScore(rankAt(analysis.scenario,
|
|
|
1).score) }} 分</span></div>
|
|
|
</td>
|
|
|
<td><el-input v-model.trim="analysis.differenceAnalysis" type="textarea"
|
|
|
:rows="3" maxlength="2000" show-word-limit
|
|
|
placeholder="结合分差、采购侧重点与渠道能力进行分析" /></td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
<div class="recommendation-card">
|
|
|
<label for="procurement-recommendation">综合采购建议</label>
|
|
|
<el-input id="procurement-recommendation" v-model.trim="procurementRecommendation"
|
|
|
type="textarea" :rows="5" maxlength="3000" show-word-limit
|
|
|
placeholder="说明试产与量产阶段的采购组合、切换原则及执行建议" />
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="analysis-panel">
|
|
|
<div class="section-heading"><span>02</span>
|
|
|
<div>
|
|
|
<h3>供应风险评估</h3>
|
|
|
<p>识别风险并制定应对措施。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="row-actions">
|
|
|
<el-button :icon="Plus" @click="addRiskRow">增加一行</el-button>
|
|
|
<el-button :icon="Delete" @click="removeRiskRow">删除一行</el-button>
|
|
|
</div>
|
|
|
<p class="panel-tip">在企业实际采购中,选定供应商后还需评估潜在供应风险,制定风险应对措施,确保供应链稳定性。结合已选定的供应商,评估供应风险并制定应对措施。</p>
|
|
|
<div class="table-scroll">
|
|
|
<table class="training-table risk-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th>风险维度</th>
|
|
|
<th>风险说明</th>
|
|
|
<th>风险等级(高/中/低)</th>
|
|
|
<th>应对措施</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="(risk, index) in riskRows" :key="risk.id"
|
|
|
:class="{ 'default-risk-row': risk.locked }">
|
|
|
<td><el-input v-model.trim="risk.dimension" maxlength="80"
|
|
|
placeholder="填写风险维度" /></td>
|
|
|
<td><el-input v-model.trim="risk.description" type="textarea" :rows="3"
|
|
|
maxlength="1000" placeholder="填写风险说明" /></td>
|
|
|
<td><el-select v-model="risk.level" placeholder="请选择"><el-option label="高"
|
|
|
value="高" /><el-option label="中" value="中" /><el-option label="低"
|
|
|
value="低" /></el-select></td>
|
|
|
<td><el-input v-model.trim="risk.measure" type="textarea" :rows="3"
|
|
|
maxlength="2000" placeholder="填写应对措施" /></td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
</section>
|
|
|
</template>
|
|
|
|
|
|
<div class="step-navigation">
|
|
|
<div class="step-nav-left">
|
|
|
<button v-if="currentStep > 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(currentStep).padStart(2, "0") }} / {{ String(trainingSteps.length).padStart(2, "0") }}</span>
|
|
|
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary" :disabled="saving"
|
|
|
@click="nextStep">
|
|
|
下一步
|
|
|
<el-icon>
|
|
|
<ArrowRight />
|
|
|
</el-icon>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
</main>
|
|
|
|
|
|
<TrainingAiSidebar :task-key="TASK_KEY" :has-saved-answer="hasSavedAnswer" :progress-items="progressItems"
|
|
|
:study-tip="currentStudyTip" reference-title="供应渠道评估提示"
|
|
|
reference-text="· 试产阶段重点关注交付周期与采购灵活性<br />· 量产阶段重点关注采购成本与质量可靠性<br />· 风险建议应包含明确的应对措施"
|
|
|
assist-text="建议先区分试产与量产的评估侧重点,再基于评分结果形成采购建议和供应风险应对方案。" />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
import { computed, onMounted, ref } from "vue"
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, DataAnalysis, Delete, Plus } 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 { parseTrainingArray } from "@/views/training/taskKeyMap"
|
|
|
import {
|
|
|
getStudentTrainingAnswer,
|
|
|
saveStudentTrainingAnswer,
|
|
|
validateSupplierEvaluationStepOne,
|
|
|
validateSupplierEvaluationStepTwo,
|
|
|
validateSupplierEvaluationStepThree,
|
|
|
} from "@/api/studentTrainingAnswer"
|
|
|
|
|
|
const TASK_KEY = "supplier-evaluation"
|
|
|
const loading = ref(false)
|
|
|
const saving = ref(false)
|
|
|
const briefOpen = ref(true)
|
|
|
const currentStep = ref(1)
|
|
|
const stepOneSaved = ref(false)
|
|
|
const stepTwoSaved = ref(false)
|
|
|
const taskConfig = ref({})
|
|
|
const DEFAULT_STEPS = ["设置评估权重", "渠道评分与对比", "采购建议与风险"]
|
|
|
const trainingSteps = computed(() => {
|
|
|
const fromConfig = parseTrainingArray(taskConfig.value?.steps)
|
|
|
return fromConfig.length ? fromConfig.slice(0, DEFAULT_STEPS.length) : DEFAULT_STEPS
|
|
|
})
|
|
|
const taskTitle = computed(() => taskConfig.value?.taskName || "供应渠道评估")
|
|
|
const taskBackground = computed(() => taskConfig.value?.background || "供应渠道会直接影响产品的采购成本、交付稳定性与质量风险,需要分别针对试产和量产场景建立评估标准。 ")
|
|
|
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives ?? taskConfig.value?.goals, ["根据试产与量产场景设置供应渠道评估权重", "完成渠道评分、采购建议与供应风险分析"]))
|
|
|
const taskRequirement = computed(() => taskConfig.value?.requirements || taskConfig.value?.requirement || "完成各步骤校验并保存,渠道评分、采购建议和供应风险应与案例资料及评估结果保持一致。")
|
|
|
const hasSavedAnswer = computed(() => stepOneSaved.value || stepTwoSaved.value || Boolean(procurementRecommendation.value.trim()))
|
|
|
const currentStudyTip = computed(() => {
|
|
|
const tips = {
|
|
|
1: "先区分试产与量产的采购重点,确保每个场景的权重合计为 100%。",
|
|
|
2: "逐项填写渠道评分,重点检查渠道名称和加权评分是否与已保存权重一致。",
|
|
|
3: "根据排序结果形成采购建议,并为每类供应风险制定可执行的应对措施。",
|
|
|
}
|
|
|
return tips[currentStep.value] || tips[1]
|
|
|
})
|
|
|
const progressItems = computed(() => [
|
|
|
{ text: "设置评估权重", status: stepOneSaved.value ? "done" : currentStep.value === 1 ? "doing" : "" },
|
|
|
{ text: "渠道评分与对比", status: stepTwoSaved.value ? "done" : currentStep.value === 2 ? "doing" : "" },
|
|
|
{ text: "采购建议与风险", status: currentStep.value === 3 ? "doing" : "" },
|
|
|
])
|
|
|
|
|
|
const rows = ref([
|
|
|
{ key: "procurementCost", name: "采购成本", description: "元器件/模组的单台采购总成本,含运费、税费", smallBatchWeight: null, largeBatchWeight: null },
|
|
|
{ key: "deliveryCycle", name: "交货周期", description: "从下单到收货的时间,影响试产/生产进度", smallBatchWeight: null, largeBatchWeight: null },
|
|
|
{ key: "qualityReliability", name: "品质可靠性", description: "元器件是否为正品,有无翻新/故障,规格匹配度", smallBatchWeight: null, largeBatchWeight: null },
|
|
|
{ key: "procurementFlexibility", name: "采购灵活性", description: "是否支持小批量拆卖、零散采购、退换货", smallBatchWeight: null, largeBatchWeight: null },
|
|
|
{ key: "serviceSupport", name: "服务支持", description: "是否提供技术文档、售后支持,退换货是否方便", smallBatchWeight: null, largeBatchWeight: null },
|
|
|
])
|
|
|
|
|
|
const DEFAULT_RISKS = [
|
|
|
{ dimension: "独家供应风险", description: "仅依赖单一渠道,供应中断时无替代方案", level: "中", measure: "建立2-3家备选渠道,试产阶段分散下单验证品质,量产阶段保留备选供应商每季度询价" },
|
|
|
{ dimension: "交期延误风险", description: "供应商交期不稳定,影响生产排期", level: "中", measure: "建立2-3家备选供应商,试产阶段零散采购,量产阶段多家供应商共同供货" },
|
|
|
{ dimension: "品质波动风险", description: "元器件批次间品质不一致,影响产品一致性", level: "高", measure: "建立2-3家备选供应商,不间断引入新供应商,每批次元器件到货后进行严格质检再入库" },
|
|
|
{ dimension: "价格波动风险", description: "原材料价格波动或供应商调价导致采购成本变化", level: "中", measure: "建立2-3家备选供应商,尝试引入新供应商,每季度询价,研判原料价格走势提前备货" },
|
|
|
]
|
|
|
|
|
|
function normalizeScores(source = {}) {
|
|
|
const savedScores = Array.isArray(source)
|
|
|
? new Map(source.map((item) => [item?.dimension, item?.score]))
|
|
|
: null
|
|
|
return rows.value.reduce((result, item) => {
|
|
|
result[item.key] = Number(savedScores ? savedScores.get(item.name) : source[item.key]) || 0
|
|
|
return result
|
|
|
}, {})
|
|
|
}
|
|
|
|
|
|
function createChannelRows(smallSaved = [], largeSaved = []) {
|
|
|
const small = Array.isArray(smallSaved) ? smallSaved : []
|
|
|
const large = Array.isArray(largeSaved) ? largeSaved : []
|
|
|
return Array.from({ length: 5 }, (_, index) => {
|
|
|
const s = small[index] || {}
|
|
|
const l = large[index] || {}
|
|
|
return {
|
|
|
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
|
|
|
channel: String(s.channel || l.channel || ""),
|
|
|
scores: normalizeScores(s.scores ?? l.scores),
|
|
|
smallWeightedTotal: s.weightedTotal ?? null,
|
|
|
largeWeightedTotal: l.weightedTotal ?? null,
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function createScenarioAnalyses(savedRows = []) {
|
|
|
const source = Array.isArray(savedRows) ? savedRows : []
|
|
|
const byScenario = new Map(source.map((item) => [item?.scenario, item]))
|
|
|
return [
|
|
|
{ scenario: "smallBatch", label: "小批量试产(100台)", differenceAnalysis: String(byScenario.get("smallBatch")?.differenceAnalysis || "") },
|
|
|
{ scenario: "largeBatch", label: "大规模量产(10000台)", differenceAnalysis: String(byScenario.get("largeBatch")?.differenceAnalysis || "") },
|
|
|
]
|
|
|
}
|
|
|
|
|
|
function createRiskRows(savedRows = []) {
|
|
|
const source = Array.isArray(savedRows) && savedRows.length ? savedRows : DEFAULT_RISKS
|
|
|
return source.map((item, index) => ({
|
|
|
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
|
|
|
dimension: String(item?.dimension || ""),
|
|
|
description: String(item?.description || ""),
|
|
|
level: ["高", "中", "低"].includes(item?.level) ? item.level : "中",
|
|
|
measure: String(item?.measure || ""),
|
|
|
locked: index < DEFAULT_RISKS.length,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
const channelRows = ref(createChannelRows())
|
|
|
const scenarioAnalyses = ref(createScenarioAnalyses())
|
|
|
const procurementRecommendation = ref("")
|
|
|
const riskRows = ref(createRiskRows())
|
|
|
|
|
|
const smallWeightTotal = computed(() => rows.value.reduce((sum, row) => sum + Number(row.smallBatchWeight || 0), 0))
|
|
|
const largeWeightTotal = computed(() => rows.value.reduce((sum, row) => sum + Number(row.largeBatchWeight || 0), 0))
|
|
|
const smallWeights = computed(() => rows.value.reduce((result, row) => ({ ...result, [row.key]: Number(row.smallBatchWeight || 0) }), {}))
|
|
|
const largeWeights = computed(() => rows.value.reduce((result, row) => ({ ...result, [row.key]: Number(row.largeBatchWeight || 0) }), {}))
|
|
|
|
|
|
function scoreFor(row, key) {
|
|
|
if (!Object.prototype.hasOwnProperty.call(row.scores, key)) row.scores[key] = 0
|
|
|
return {
|
|
|
get score() { return row.scores[key] },
|
|
|
set score(value) { row.scores[key] = value; row.smallWeightedTotal = null; row.largeWeightedTotal = null },
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function toNumber(value) {
|
|
|
const numeric = Number(value)
|
|
|
return Number.isFinite(numeric) ? numeric : 0
|
|
|
}
|
|
|
|
|
|
function weightedScore(row, scenario) {
|
|
|
const weights = scenario === "smallBatch" ? smallWeights.value : largeWeights.value
|
|
|
const score = rows.value.reduce((sum, item) => sum + toNumber(row.scores[item.key]) * toNumber(weights[item.key]) / 100, 0)
|
|
|
return score.toFixed(2)
|
|
|
}
|
|
|
|
|
|
function channelRanking(scenario) {
|
|
|
const storedKey = scenario === "smallBatch" ? "smallWeightedTotal" : "largeWeightedTotal"
|
|
|
return channelRows.value
|
|
|
.map((row) => {
|
|
|
const stored = Number(row[storedKey])
|
|
|
return { channel: String(row.channel || "").trim(), score: Number.isFinite(stored) ? stored : Number(weightedScore(row, scenario)) }
|
|
|
})
|
|
|
.filter((item) => item.channel)
|
|
|
.sort((left, right) => right.score - left.score || left.channel.localeCompare(right.channel, "zh-CN"))
|
|
|
}
|
|
|
|
|
|
function rankAt(scenario, index) {
|
|
|
return channelRanking(scenario)[index] || {}
|
|
|
}
|
|
|
|
|
|
function formatScore(value) {
|
|
|
const score = Number(value)
|
|
|
return Number.isFinite(score) ? score.toFixed(2) : "--"
|
|
|
}
|
|
|
|
|
|
function buildStepOnePayload() {
|
|
|
return {
|
|
|
items: rows.value.map((row) => ({
|
|
|
dimension: row.name,
|
|
|
smallBatchWeight: Number(row.smallBatchWeight || 0),
|
|
|
largeBatchWeight: Number(row.largeBatchWeight || 0),
|
|
|
})),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function buildStepTwoPayload() {
|
|
|
const serializeChannels = (storedKey) => channelRows.value.map((row) => ({
|
|
|
channel: String(row.channel || "").trim(),
|
|
|
scores: rows.value.map((dimension) => ({
|
|
|
dimension: dimension.name,
|
|
|
score: toNumber(row.scores[dimension.key]),
|
|
|
})),
|
|
|
weightedTotal: row[storedKey],
|
|
|
}))
|
|
|
return {
|
|
|
smallBatchChannels: serializeChannels("smallWeightedTotal"),
|
|
|
largeBatchChannels: serializeChannels("largeWeightedTotal"),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function buildStepThreePayload() {
|
|
|
return {
|
|
|
scenarioAnalyses: scenarioAnalyses.value.map((item) => ({
|
|
|
scenario: item.scenario,
|
|
|
bestChannel: rankAt(item.scenario, 0).channel || "",
|
|
|
secondChannel: rankAt(item.scenario, 1).channel || "",
|
|
|
differenceAnalysis: item.differenceAnalysis,
|
|
|
})),
|
|
|
procurementRecommendation: procurementRecommendation.value,
|
|
|
risks: riskRows.value.map((item) => ({ dimension: item.dimension, description: item.description, level: item.level, measure: item.measure })),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function parseJson(value) {
|
|
|
if (!value) return null
|
|
|
if (typeof value === "object") return value
|
|
|
try { return JSON.parse(value) } catch { return null }
|
|
|
}
|
|
|
|
|
|
function applyStepOne(answer) {
|
|
|
const result = parseJson(answer?.step1Answer)
|
|
|
if (!Array.isArray(result?.items)) return false
|
|
|
const source = new Map(result.items.map((item) => [item.dimension, item]))
|
|
|
rows.value.forEach((row) => {
|
|
|
const saved = source.get(row.name)
|
|
|
if (!saved) return
|
|
|
row.smallBatchWeight = toNumber(saved.smallBatchWeight)
|
|
|
row.largeBatchWeight = toNumber(saved.largeBatchWeight)
|
|
|
})
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
function applyStepTwo(answer) {
|
|
|
const result = parseJson(answer?.step2Answer)
|
|
|
if (!Array.isArray(result?.smallBatchChannels) || !Array.isArray(result?.largeBatchChannels)) return false
|
|
|
channelRows.value = createChannelRows(result.smallBatchChannels, result.largeBatchChannels)
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
function applyStepThree(answer) {
|
|
|
const result = parseJson(answer?.step3Answer)
|
|
|
if (!Array.isArray(result?.scenarioAnalyses) || !Array.isArray(result?.risks)) return false
|
|
|
scenarioAnalyses.value = createScenarioAnalyses(result.scenarioAnalyses)
|
|
|
procurementRecommendation.value = String(result.procurementRecommendation || "")
|
|
|
riskRows.value = createRiskRows(result.risks)
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
async function saveStepOne(advance = false) {
|
|
|
if (saving.value) return
|
|
|
saving.value = true
|
|
|
try {
|
|
|
const result = (await validateSupplierEvaluationStepOne(buildStepOnePayload()))?.data || {}
|
|
|
if (!result?.valid) throw new Error(result?.message || "权重校验未通过")
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { currentStep: advance ? 2 : 1, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step1Answer: JSON.stringify({ items: result.items }) })
|
|
|
stepOneSaved.value = true
|
|
|
if (advance) {
|
|
|
currentStep.value = 2
|
|
|
ElMessage.success("权重已保存,请继续完成渠道评分")
|
|
|
} else {
|
|
|
ElMessage.success("当前步骤已保存")
|
|
|
}
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查权重填写")
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function saveStepTwo() {
|
|
|
if (saving.value) return false
|
|
|
saving.value = true
|
|
|
try {
|
|
|
const result = (await validateSupplierEvaluationStepTwo(buildStepTwoPayload()))?.data || {}
|
|
|
if (!result?.valid) throw new Error(result?.message || "渠道评分校验未通过")
|
|
|
channelRows.value = createChannelRows(result.smallBatchChannels, result.largeBatchChannels)
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { currentStep: 2, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step2Answer: JSON.stringify({ weights: result.weights, smallBatchChannels: result.smallBatchChannels, largeBatchChannels: result.largeBatchChannels }) })
|
|
|
stepTwoSaved.value = true
|
|
|
ElMessage.success("渠道评分已保存,可进入第 3 步形成采购建议")
|
|
|
return true
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查渠道和评分")
|
|
|
return false
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function saveStepThree() {
|
|
|
if (saving.value) return
|
|
|
saving.value = true
|
|
|
try {
|
|
|
const result = (await validateSupplierEvaluationStepThree(buildStepThreePayload()))?.data || {}
|
|
|
if (!result?.valid) throw new Error(result?.message || "第 3 步校验未通过")
|
|
|
const step3Answer = { scenarioAnalyses: result.scenarioAnalyses, procurementRecommendation: result.procurementRecommendation, risks: result.risks }
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, { currentStep: 3, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step3Answer: JSON.stringify(step3Answer) })
|
|
|
applyStepThree({ step3Answer })
|
|
|
ElMessage.success("采购建议与供应风险已保存")
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查填写内容")
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function saveCurrentProgress() {
|
|
|
if (currentStep.value === 1) return saveStepOne(false)
|
|
|
if (currentStep.value === 2) return saveStepTwo()
|
|
|
return saveStepThree()
|
|
|
}
|
|
|
|
|
|
function previousStep() {
|
|
|
if (currentStep.value > 1) currentStep.value -= 1
|
|
|
}
|
|
|
|
|
|
async function nextStep() {
|
|
|
if (currentStep.value === 1) return saveStepOne(true)
|
|
|
if (currentStep.value === 2) {
|
|
|
const saved = await saveStepTwo()
|
|
|
if (saved) currentStep.value = 3
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function goToStepFromTab(step) {
|
|
|
if (step === 2) return openStepTwo()
|
|
|
if (step === 3) return openStepThree()
|
|
|
currentStep.value = 1
|
|
|
}
|
|
|
|
|
|
function openStepTwo() {
|
|
|
if (!stepOneSaved.value) {
|
|
|
ElMessage.warning("请先完成并保存第一步权重设置")
|
|
|
return
|
|
|
}
|
|
|
currentStep.value = 2
|
|
|
}
|
|
|
|
|
|
function openStepThree() {
|
|
|
if (!stepOneSaved.value || !stepTwoSaved.value) {
|
|
|
ElMessage.warning("请先完成并保存前两步内容")
|
|
|
return
|
|
|
}
|
|
|
currentStep.value = 3
|
|
|
}
|
|
|
|
|
|
function addRiskRow() {
|
|
|
if (riskRows.value.length >= 10) {
|
|
|
ElMessage.warning("供应风险最多填写 10 条")
|
|
|
return
|
|
|
}
|
|
|
riskRows.value.push({ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, dimension: "", description: "", level: "中", measure: "", locked: false })
|
|
|
}
|
|
|
|
|
|
function removeRiskRow() {
|
|
|
const index = [...riskRows.value].map((row, index) => ({ row, index })).reverse().find((item) => !item.row.locked)?.index
|
|
|
if (index === undefined) {
|
|
|
ElMessage.warning("默认的 4 条供应风险不可删除")
|
|
|
return
|
|
|
}
|
|
|
riskRows.value.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
onMounted(async () => {
|
|
|
loading.value = true
|
|
|
try {
|
|
|
taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || {}
|
|
|
} catch (_) {
|
|
|
taskConfig.value = {}
|
|
|
}
|
|
|
try {
|
|
|
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data || {}
|
|
|
stepOneSaved.value = applyStepOne(answer)
|
|
|
stepTwoSaved.value = applyStepTwo(answer)
|
|
|
applyStepThree(answer)
|
|
|
if (Number(answer?.currentStep) === 3 && stepOneSaved.value && stepTwoSaved.value) currentStep.value = 3
|
|
|
else if (Number(answer?.currentStep) === 2 && stepOneSaved.value) currentStep.value = 2
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "页面数据加载失败")
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
/* ===== 页面级令牌(教育蓝极简主题) ===== */
|
|
|
.supplier-page {
|
|
|
--student-accent: #2563eb;
|
|
|
--student-accent-deep: #1d4ed8;
|
|
|
--student-accent-light: #3b82f6;
|
|
|
--student-accent-soft: #eff6ff;
|
|
|
--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;
|
|
|
--el-color-primary: #2563eb;
|
|
|
--el-color-primary-light-3: #60a5fa;
|
|
|
--el-color-primary-light-5: #93c5fd;
|
|
|
--el-color-primary-light-7: #bfdbfe;
|
|
|
--el-color-primary-light-8: #dbeafe;
|
|
|
--el-color-primary-light-9: #eff6ff;
|
|
|
--el-color-primary-dark-2: #1d4ed8;
|
|
|
--el-input-hover-border-color: #3b82f6;
|
|
|
--el-input-focus-border-color: #2563eb;
|
|
|
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
|
|
|
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
|
|
|
|
|
|
min-height: 100%;
|
|
|
min-width: 0;
|
|
|
padding: 24px;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
/* supplier-core:只设 gap,无边框/背景/阴影 */
|
|
|
.supplier-core {
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
align-content: start;
|
|
|
min-width: 0;
|
|
|
border: 0;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
padding: 0;
|
|
|
}
|
|
|
|
|
|
/* ===== task-header(独立白卡片) ===== */
|
|
|
.task-header {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
gap: 16px;
|
|
|
margin: 0;
|
|
|
padding: 18px 20px 18px 24px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 12px;
|
|
|
background: var(--student-card-bg);
|
|
|
overflow: hidden;
|
|
|
|
|
|
&::before {
|
|
|
content: "";
|
|
|
position: absolute;
|
|
|
top: 0;
|
|
|
bottom: 0;
|
|
|
left: 0;
|
|
|
width: 4px;
|
|
|
background: var(--student-accent);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.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 {
|
|
|
margin: 0;
|
|
|
padding: 10px 16px;
|
|
|
|
|
|
:deep(.training-task-toolbar) {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: flex-end;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== supplier-workbench(透明容器) ===== */
|
|
|
.supplier-workbench {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
min-width: 0;
|
|
|
}
|
|
|
|
|
|
/* ===== intro-card:改为 section-eyebrow + step-desc 风格 ===== */
|
|
|
.intro-card {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 4px;
|
|
|
}
|
|
|
|
|
|
.intro-card.compact {
|
|
|
margin-bottom: 0;
|
|
|
}
|
|
|
|
|
|
.step-caption {
|
|
|
margin: 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.12em;
|
|
|
text-transform: uppercase;
|
|
|
}
|
|
|
|
|
|
.intro-card h2 {
|
|
|
margin: 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h1);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.35;
|
|
|
}
|
|
|
|
|
|
.intro-card>p:last-child {
|
|
|
margin: 4px 0 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
/* ===== weights-grid:去掉双列 grid(改为单列 + 内部 weight-card 透明化) ===== */
|
|
|
.weights-grid {
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
margin-top: 0;
|
|
|
}
|
|
|
|
|
|
.weight-card,
|
|
|
.score-section {
|
|
|
padding: 14px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: var(--student-card-bg);
|
|
|
}
|
|
|
|
|
|
/* ===== supplier-step-card:步骤标题与内容统一装入同一卡片框(与其他实训页一致) ===== */
|
|
|
.supplier-step-card {
|
|
|
padding: 17px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: var(--student-card-bg);
|
|
|
display: grid;
|
|
|
gap: 14px;
|
|
|
}
|
|
|
|
|
|
.card-title {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
gap: 12px;
|
|
|
margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
.card-title span {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.07em;
|
|
|
}
|
|
|
|
|
|
.card-title h3 {
|
|
|
margin: 2px 0 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h2);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.card-title strong {
|
|
|
color: var(--student-accent);
|
|
|
font-size: var(--student-fs-data);
|
|
|
font-weight: 800;
|
|
|
|
|
|
&.danger {
|
|
|
color: var(--student-danger);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 表格 ===== */
|
|
|
.training-table {
|
|
|
width: 100%;
|
|
|
border-collapse: collapse;
|
|
|
table-layout: fixed;
|
|
|
overflow: hidden;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
}
|
|
|
|
|
|
.training-table th {
|
|
|
padding: 10px 12px;
|
|
|
color: var(--student-text);
|
|
|
background: #fafbfc;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
font-weight: 800;
|
|
|
text-align: left;
|
|
|
}
|
|
|
|
|
|
.training-table td {
|
|
|
padding: 10px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
color: var(--student-text);
|
|
|
vertical-align: middle;
|
|
|
text-align: center;
|
|
|
background: transparent;
|
|
|
}
|
|
|
|
|
|
.training-table tbody tr:nth-child(even) {
|
|
|
background: transparent;
|
|
|
}
|
|
|
|
|
|
.training-table :deep(.el-input__wrapper),
|
|
|
.training-table :deep(.el-textarea__inner),
|
|
|
.training-table :deep(.el-select__wrapper) {
|
|
|
background: #ffffff;
|
|
|
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
|
|
|
}
|
|
|
|
|
|
.training-table :deep(.el-input__inner),
|
|
|
.training-table :deep(.el-textarea__inner),
|
|
|
.training-table :deep(.el-select__selected-item),
|
|
|
.training-table :deep(.el-input-number__decrease),
|
|
|
.training-table :deep(.el-input-number__increase) {
|
|
|
color: var(--student-text);
|
|
|
}
|
|
|
|
|
|
.training-table :deep(.el-textarea__inner) {
|
|
|
min-height: 70px !important;
|
|
|
resize: vertical;
|
|
|
}
|
|
|
|
|
|
/* ===== 操作按钮区 ===== */
|
|
|
.row-actions :deep(.el-button) {
|
|
|
height: 36px;
|
|
|
min-height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(31, 41, 55, 0.4);
|
|
|
border-radius: 8px;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 700;
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
.row-actions :deep(.el-button:hover) {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
color: var(--student-accent);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
|
|
|
}
|
|
|
|
|
|
.row-actions :deep(.el-button--primary) {
|
|
|
border-color: var(--student-accent);
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
|
|
|
}
|
|
|
|
|
|
.row-actions :deep(.el-button--primary:hover) {
|
|
|
border-color: var(--student-accent-deep);
|
|
|
background: var(--student-accent-deep);
|
|
|
color: #ffffff;
|
|
|
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
|
|
|
}
|
|
|
|
|
|
/* ===== 底部步骤导航按钮 ===== */
|
|
|
.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;
|
|
|
}
|
|
|
|
|
|
/* ===== score-section / analysis-panel ===== */
|
|
|
.score-section {
|
|
|
margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
.table-scroll {
|
|
|
overflow-x: auto;
|
|
|
}
|
|
|
|
|
|
.score-table {
|
|
|
min-width: 960px;
|
|
|
}
|
|
|
|
|
|
.score-table th:first-child,
|
|
|
.score-table td:first-child {
|
|
|
width: 175px;
|
|
|
}
|
|
|
|
|
|
.score-table :deep(.el-input-number) {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
/* 合并评分表:双列组布局 */
|
|
|
.merged-score-table {
|
|
|
min-width: 1080px;
|
|
|
table-layout: fixed;
|
|
|
}
|
|
|
|
|
|
.merged-score-table .col-channel {
|
|
|
width: 150px;
|
|
|
}
|
|
|
|
|
|
.merged-score-table .col-dimension {
|
|
|
width: auto;
|
|
|
}
|
|
|
|
|
|
.merged-score-table .col-total {
|
|
|
width: 110px;
|
|
|
}
|
|
|
|
|
|
/* 顶层分组表头:维度组中性底,总分组着色 */
|
|
|
.merged-score-table .group-header th {
|
|
|
text-align: center;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
.merged-score-table .group-dimensions {
|
|
|
background: #fafbfc;
|
|
|
color: var(--student-text);
|
|
|
}
|
|
|
|
|
|
.merged-score-table .group-total {
|
|
|
color: #ffffff;
|
|
|
font-weight: 800;
|
|
|
}
|
|
|
|
|
|
.merged-score-table .group-total-small {
|
|
|
background: var(--student-accent);
|
|
|
}
|
|
|
|
|
|
.merged-score-table .group-total-large {
|
|
|
background: var(--student-accent-deep);
|
|
|
}
|
|
|
|
|
|
/* 第二行子表头 */
|
|
|
.merged-score-table .col-dimension,
|
|
|
.merged-score-table .col-total {
|
|
|
background: #fafbfc;
|
|
|
}
|
|
|
|
|
|
.merged-score-table .col-total-small {
|
|
|
color: var(--student-accent);
|
|
|
}
|
|
|
|
|
|
.merged-score-table .col-total-large {
|
|
|
color: var(--student-accent-deep);
|
|
|
}
|
|
|
|
|
|
/* 列组竖向分隔线 */
|
|
|
.merged-score-table .col-channel,
|
|
|
.merged-score-table .col-total-small {
|
|
|
border-right: 2px solid var(--student-panel-border);
|
|
|
}
|
|
|
|
|
|
.total-score {
|
|
|
color: var(--student-accent) !important;
|
|
|
font-size: var(--student-fs-data);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.analysis-panel {
|
|
|
padding: 14px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: var(--student-card-bg);
|
|
|
}
|
|
|
|
|
|
.analysis-panel+.analysis-panel {
|
|
|
margin-top: 10px;
|
|
|
}
|
|
|
|
|
|
.ranking-table {
|
|
|
min-width: 940px;
|
|
|
}
|
|
|
|
|
|
.ranking-table th:first-child {
|
|
|
width: 18%;
|
|
|
}
|
|
|
|
|
|
.ranking-table th:nth-child(2),
|
|
|
.ranking-table th:nth-child(3) {
|
|
|
width: 19%;
|
|
|
}
|
|
|
|
|
|
.scene-name {
|
|
|
color: var(--student-accent) !important;
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.rank-result {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 4px;
|
|
|
}
|
|
|
|
|
|
.rank-result strong {
|
|
|
color: var(--student-text);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.rank-result span {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
.recommendation-card {
|
|
|
margin-top: 10px;
|
|
|
padding: 12px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.recommendation-card label {
|
|
|
display: block;
|
|
|
margin-bottom: 8px;
|
|
|
color: var(--student-text);
|
|
|
font-weight: 700;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
.recommendation-card :deep(.el-textarea__inner) {
|
|
|
min-height: 120px !important;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
|
|
|
}
|
|
|
|
|
|
.row-actions {
|
|
|
display: flex;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
.panel-tip {
|
|
|
margin: -2px 0 12px;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.risk-table {
|
|
|
min-width: 1080px;
|
|
|
}
|
|
|
|
|
|
.risk-table th:nth-child(1) {
|
|
|
width: 15%;
|
|
|
}
|
|
|
|
|
|
.risk-table th:nth-child(2) {
|
|
|
width: 29%;
|
|
|
}
|
|
|
|
|
|
.risk-table th:nth-child(3) {
|
|
|
width: 16%;
|
|
|
}
|
|
|
|
|
|
.risk-table :deep(.el-select) {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.default-risk-row :deep(.el-input__inner),
|
|
|
.default-risk-row :deep(.el-textarea__inner),
|
|
|
.default-risk-row :deep(.el-select__selected-item) {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-style: italic;
|
|
|
}
|
|
|
|
|
|
/* ===== 响应式(仅基础断点) ===== */
|
|
|
@media (max-width: 840px) {
|
|
|
.weights-grid {
|
|
|
grid-template-columns: 1fr;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
.supplier-page {
|
|
|
padding: 12px;
|
|
|
}
|
|
|
|
|
|
.task-header {
|
|
|
flex-direction: column;
|
|
|
align-items: flex-start;
|
|
|
}
|
|
|
|
|
|
.step-navigation {
|
|
|
flex-direction: column;
|
|
|
align-items: stretch;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.btn-nav {
|
|
|
width: 100%;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 小节标题(蓝底序号,与 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">
|
|
|
/* ============================================================
|
|
|
* 教育蓝极简换肤 · channel/supplier.vue
|
|
|
* ============================================================ */
|
|
|
|
|
|
/* 画布背景 */
|
|
|
#app .studentLayout .app-main:has(.supplier-page) {
|
|
|
background: #ffffff !important;
|
|
|
}
|
|
|
|
|
|
/* el-table 基础换肤 */
|
|
|
:has(.supplier-page) {
|
|
|
.el-table {
|
|
|
--el-table-bg-color: #ffffff !important;
|
|
|
--el-table-tr-bg-color: transparent !important;
|
|
|
--el-table-header-bg-color: #fafbfc !important;
|
|
|
--el-table-row-hover-bg-color: rgba(37, 99, 235, 0.04) !important;
|
|
|
line-height: 1.55;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 步骤导航:分段连接胶囊(教育蓝极简)
|
|
|
* ============================================================ */
|
|
|
#app .studentLayout .app-main:has(.supplier-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: 0 0 20px !important;
|
|
|
padding: 0 !important;
|
|
|
border: none !important;
|
|
|
border-radius: 0 !important;
|
|
|
background: transparent !important;
|
|
|
box-shadow: none !important;
|
|
|
|
|
|
.step-tab {
|
|
|
|
|
|
|
|
|
display: flex !important;
|
|
|
flex-direction: row !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 8px !important;
|
|
|
min-width: 0 !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;
|
|
|
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;
|
|
|
white-space: nowrap !important;
|
|
|
text-align: center !important;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease !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;
|
|
|
}
|
|
|
|
|
|
.step-bar {
|
|
|
display: none !important;
|
|
|
}
|
|
|
|
|
|
.step-no,
|
|
|
.step-marker {
|
|
|
position: static !important;
|
|
|
flex: 0 0 auto !important;
|
|
|
display: inline-flex !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
min-width: 22px !important;
|
|
|
height: 22px !important;
|
|
|
padding: 0 5px !important;
|
|
|
border-radius: 6px !important;
|
|
|
background: #f3f4f6 !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
|
|
|
font-size: var(--student-fs-body-sm) !important;
|
|
|
font-weight: 700 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition: all 0.16s ease !important;
|
|
|
|
|
|
&::after {
|
|
|
display: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.step-name {
|
|
|
color: inherit !important;
|
|
|
}
|
|
|
|
|
|
.step-copy {
|
|
|
margin-left: 0 !important;
|
|
|
gap: 4px !important;
|
|
|
}
|
|
|
|
|
|
&:hover:not(.active):not(.complete) {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
color: #2563eb !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
.step-no,
|
|
|
.step-marker {
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
&.complete:not(.active) {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #374151 !important;
|
|
|
|
|
|
.step-no,
|
|
|
.step-marker {
|
|
|
background: rgba(43, 164, 113, 0.12) !important;
|
|
|
color: #16915f !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2ba471 !important;
|
|
|
background: #f0fdf6 !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
.step-no,
|
|
|
.step-marker {
|
|
|
background: #2ba471 !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
&.active {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
font-weight: 700 !important;
|
|
|
z-index: 2 !important;
|
|
|
|
|
|
.step-no,
|
|
|
.step-marker {
|
|
|
background: #ffffff !important;
|
|
|
color: #2563eb !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
background: #1d4ed8 !important;
|
|
|
border-color: #1d4ed8 !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.step-connector {
|
|
|
display: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 共用组件换肤:TrainingMaterialButton 资料按钮
|
|
|
* ============================================================ */
|
|
|
.supplier-page .workbench-actions .material-button.el-button,
|
|
|
.supplier-page .supplier-toolbar .material-button.el-button {
|
|
|
height: 34px !important;
|
|
|
min-height: 34px !important;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
|
|
border-radius: 8px !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #ffffff !important;
|
|
|
font-size: var(--student-fs-body) !important;
|
|
|
font-weight: 600 !important;
|
|
|
transition:
|
|
|
border-color 0.16s ease,
|
|
|
background 0.16s ease,
|
|
|
color 0.16s ease,
|
|
|
box-shadow 0.16s ease !important;
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2563eb !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
|
|
|
}
|
|
|
|
|
|
&.is-plain {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #9ca3af !important;
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #9ca3af !important;
|
|
|
background: #ffffff !important;
|
|
|
box-shadow: none !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 顶部导出按钮(outline-action)
|
|
|
* ============================================================ */
|
|
|
.supplier-page .workbench-actions>button.outline-action {
|
|
|
display: inline-flex !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 6px !important;
|
|
|
height: 34px !important;
|
|
|
min-height: 34px !important;
|
|
|
padding: 0 14px !important;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
|
|
border-radius: 8px !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #ffffff !important;
|
|
|
font-size: var(--student-fs-body) !important;
|
|
|
font-weight: 600 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition:
|
|
|
border-color 0.16s ease,
|
|
|
background 0.16s ease,
|
|
|
color 0.16s ease,
|
|
|
box-shadow 0.16s ease !important;
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2563eb !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
|
|
|
transform: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 窄屏:横向滑动
|
|
|
* ============================================================ */
|
|
|
@media (max-width: 900px) {
|
|
|
#app .studentLayout .app-main:has(.supplier-page) .step-indicator {
|
|
|
justify-content: flex-start !important;
|
|
|
overflow-x: auto !important;
|
|
|
}
|
|
|
|
|
|
#app .studentLayout .app-main:has(.supplier-page) .step-indicator .step-tab {
|
|
|
flex: 0 0 140px !important;
|
|
|
max-width: none !important;
|
|
|
}
|
|
|
}
|
|
|
</style>
|