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/channel/supplier.vue

1649 lines
57 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="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>