|
|
<template>
|
|
|
<div class="supplier-page student-training-shell" v-loading="loading">
|
|
|
<main class="training-core">
|
|
|
<section class="workspace-shell">
|
|
|
<header class="workspace-header">
|
|
|
<div>
|
|
|
<p class="eyebrow">SUPPLIER CHANNEL EVALUATION</p>
|
|
|
<h1>{{ taskTitle }}</h1>
|
|
|
<p>分别从试产与量产场景评估供应渠道,形成可执行的采购与风险管理建议。</p>
|
|
|
</div>
|
|
|
<div class="workspace-header__actions">
|
|
|
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
|
|
|
<span class="task-badge">实训任务</span>
|
|
|
</div>
|
|
|
</header>
|
|
|
|
|
|
<TrainingTaskBrief
|
|
|
:background="taskBackground"
|
|
|
:goals="taskGoals"
|
|
|
:requirements="taskRequirement"
|
|
|
:show-material="false"
|
|
|
/>
|
|
|
|
|
|
<nav class="step-nav" aria-label="供应渠道评估步骤">
|
|
|
<button :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1">
|
|
|
<span class="step-marker">01</span><span class="step-copy"><b>设置评估权重</b><small>{{ currentStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span>
|
|
|
</button>
|
|
|
<span class="step-connector" aria-hidden="true"></span>
|
|
|
<button :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" :aria-current="currentStep === 2 ? 'step' : undefined" @click="openStepTwo">
|
|
|
<span class="step-marker">02</span><span class="step-copy"><b>渠道评分与对比</b><small>{{ currentStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span>
|
|
|
</button>
|
|
|
<span class="step-connector" aria-hidden="true"></span>
|
|
|
<button :class="['step-tab', { active: currentStep === 3 }]" :aria-current="currentStep === 3 ? 'step' : undefined" @click="openStepThree">
|
|
|
<span class="step-marker">03</span><span class="step-copy"><b>采购建议与风险</b><small>{{ currentStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span>
|
|
|
</button>
|
|
|
</nav>
|
|
|
|
|
|
<template v-if="currentStep === 1">
|
|
|
<section class="intro-card">
|
|
|
<p class="step-caption">Step 01</p>
|
|
|
<h2>设置两种采购场景的评估权重</h2>
|
|
|
<p>试产更关注交付周期和采购灵活性;量产更关注采购成本和品质可靠性。每个场景的权重合计必须为 100%。</p>
|
|
|
</section>
|
|
|
|
|
|
<section class="weights-grid">
|
|
|
<article class="weight-card">
|
|
|
<div class="card-title">
|
|
|
<div><span>场景 A</span><h3>小批量试产(100台)</h3></div>
|
|
|
<strong :class="{ danger: smallWeightTotal !== 100 }">{{ smallWeightTotal }}%</strong>
|
|
|
</div>
|
|
|
<table class="training-table">
|
|
|
<thead><tr><th>评估维度</th><th>维度说明</th><th>权重(%)</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>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</article>
|
|
|
|
|
|
<article class="weight-card">
|
|
|
<div class="card-title">
|
|
|
<div><span>场景 B</span><h3>大规模量产(10000台)</h3></div>
|
|
|
<strong :class="{ danger: largeWeightTotal !== 100 }">{{ largeWeightTotal }}%</strong>
|
|
|
</div>
|
|
|
<table class="training-table">
|
|
|
<thead><tr><th>评估维度</th><th>维度说明</th><th>权重(%)</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.largeBatchWeight" :min="0" :max="100" :precision="0" controls-position="right" /></td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</article>
|
|
|
</section>
|
|
|
|
|
|
<footer class="page-actions">
|
|
|
<el-button type="primary" size="large" @click="saveStepOne">保存并进入渠道评分 <el-icon><CircleCheck /></el-icon></el-button>
|
|
|
</footer>
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="currentStep === 2">
|
|
|
<section class="intro-card compact">
|
|
|
<p class="step-caption">Step 02</p>
|
|
|
<h2>为供应渠道评分并比较结果</h2>
|
|
|
<p>每项评分为 0–10 分,渠道名称和评分均由学生填写;系统会按第一步已保存的权重自动计算加权总分。</p>
|
|
|
</section>
|
|
|
|
|
|
<section v-for="section in scoreSections" :key="section.key" class="score-section">
|
|
|
<div class="section-heading">
|
|
|
<div><span>{{ section.label }}</span><h3>{{ section.title }}</h3></div>
|
|
|
<small>加权总分 = Σ(维度评分 × 权重)</small>
|
|
|
</div>
|
|
|
<div class="table-scroll">
|
|
|
<table class="training-table score-table">
|
|
|
<thead>
|
|
|
<tr><th>渠道</th><th v-for="dimension in rows" :key="dimension.key">{{ dimension.name }}</th><th>加权总分</th></tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="row in section.rows" :key="row.id">
|
|
|
<td><el-input v-model.trim="row.channel" maxlength="40" placeholder="填写渠道名称" :disabled="section.key === 'large'" @input="section.key === 'small' && syncChannelNames()" /></td>
|
|
|
<td v-for="dimension in rows" :key="dimension.key">
|
|
|
<el-input-number v-model="scoreFor(row, dimension.key).score" :min="0" :max="10" :precision="0" controls-position="right" />
|
|
|
</td>
|
|
|
<td class="total-score">{{ weightedScore(row, section.weights) }}</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<footer class="page-actions split-actions">
|
|
|
<el-button size="large" @click="currentStep = 1">上一步</el-button>
|
|
|
<el-button type="primary" size="large" @click="saveStepTwo">保存评分结果</el-button>
|
|
|
</footer>
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
<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">
|
|
|
<div><span>(一)渠道优选与场景分析</span><h3>根据评分结果形成综合采购建议</h3></div>
|
|
|
<small>排名结果为只读数据</small>
|
|
|
</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 risk-heading">
|
|
|
<div><span>(二)供应风险评估</span><h3>识别风险并制定应对措施</h3></div>
|
|
|
<div class="row-actions">
|
|
|
<el-button :icon="Plus" @click="addRiskRow">增加一行</el-button>
|
|
|
<el-button :icon="Delete" @click="removeRiskRow">删除一行</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
<p class="panel-tip">默认的 4 条风险示例不可删除;可新增至 10 条,删除操作仅作用于最后一条新增风险。斜体内容为可直接修改或复制的填写示例。</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>
|
|
|
|
|
|
<footer class="page-actions split-actions">
|
|
|
<el-button size="large" @click="currentStep = 2">上一步</el-button>
|
|
|
<el-button type="primary" size="large" @click="saveStepThree">保存第 3 步</el-button>
|
|
|
</footer>
|
|
|
</template>
|
|
|
</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 { CircleCheck, Delete, Plus } from "@element-plus/icons-vue";
|
|
|
import { ElMessage } from "element-plus";
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.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 currentStep = ref(1);
|
|
|
const stepOneSaved = ref(false);
|
|
|
const stepTwoSaved = ref(false);
|
|
|
const taskConfig = ref({});
|
|
|
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: 20, largeBatchWeight: 35 },
|
|
|
{ key: "deliveryCycle", name: "交货周期", description: "从下单到收货的时间,影响试产/生产进度", smallBatchWeight: 30, largeBatchWeight: 15 },
|
|
|
{ key: "qualityReliability", name: "品质可靠性", description: "元器件是否为正品,有无翻新/故障,规格匹配度", smallBatchWeight: 20, largeBatchWeight: 30 },
|
|
|
{ key: "procurementFlexibility", name: "采购灵活性", description: "是否支持小批量拆卖、零散采购、退换货", smallBatchWeight: 20, largeBatchWeight: 10 },
|
|
|
{ key: "serviceSupport", name: "服务支持", description: "是否提供技术文档、售后支持,退换货是否方便", smallBatchWeight: 10, largeBatchWeight: 10 },
|
|
|
]);
|
|
|
|
|
|
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 createScoreRows(savedRows = []) {
|
|
|
const source = Array.isArray(savedRows) ? savedRows : [];
|
|
|
return Array.from({ length: 5 }, (_, index) => {
|
|
|
const saved = source[index] || {};
|
|
|
return {
|
|
|
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
|
|
|
channel: String(saved.channel || ""),
|
|
|
scores: normalizeScores(saved.scores),
|
|
|
weightedTotal: saved.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 smallBatchRows = ref(createScoreRows());
|
|
|
const largeBatchRows = ref(createScoreRows());
|
|
|
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) }), {}));
|
|
|
const scoreSections = computed(() => [
|
|
|
{ key: "small", label: "场景 A", title: "小批量试产(100台)渠道评分", rows: smallBatchRows.value, weights: smallWeights.value },
|
|
|
{ key: "large", label: "场景 B", title: "大规模量产(10000台)渠道评分", rows: largeBatchRows.value, weights: largeWeights.value },
|
|
|
]);
|
|
|
|
|
|
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.weightedTotal = null; },
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function toNumber(value) {
|
|
|
const numeric = Number(value);
|
|
|
return Number.isFinite(numeric) ? numeric : 0;
|
|
|
}
|
|
|
|
|
|
function weightedScore(row, weights) {
|
|
|
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 source = scenario === "smallBatch" ? smallBatchRows.value : largeBatchRows.value;
|
|
|
const weights = scenario === "smallBatch" ? smallWeights.value : largeWeights.value;
|
|
|
return source
|
|
|
.map((row) => {
|
|
|
const stored = Number(row.weightedTotal);
|
|
|
return { channel: String(row.channel || "").trim(), score: Number.isFinite(stored) ? stored : Number(weightedScore(row, weights)) };
|
|
|
})
|
|
|
.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 syncChannelNames() {
|
|
|
largeBatchRows.value.forEach((row, index) => {
|
|
|
row.channel = String(smallBatchRows.value[index]?.channel || "").trim();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
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 = (source) => source.map((row) => ({
|
|
|
channel: String(row.channel || "").trim(),
|
|
|
scores: rows.value.map((dimension) => ({
|
|
|
dimension: dimension.name,
|
|
|
score: toNumber(row.scores[dimension.key]),
|
|
|
})),
|
|
|
}));
|
|
|
return {
|
|
|
smallBatchChannels: serializeChannels(smallBatchRows.value),
|
|
|
largeBatchChannels: serializeChannels(largeBatchRows.value),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
smallBatchRows.value = createScoreRows(result.smallBatchChannels);
|
|
|
largeBatchRows.value = createScoreRows(result.largeBatchChannels);
|
|
|
syncChannelNames();
|
|
|
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() {
|
|
|
try {
|
|
|
const result = await validateSupplierEvaluationStepOne(buildStepOnePayload());
|
|
|
if (!result?.valid) throw new Error(result?.message || "权重校验未通过");
|
|
|
await saveStudentTrainingAnswer({ taskKey: TASK_KEY, currentStep: 1, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step1Answer: JSON.stringify({ items: result.items }) });
|
|
|
stepOneSaved.value = true;
|
|
|
currentStep.value = 2;
|
|
|
ElMessage.success("权重已保存,请继续完成渠道评分");
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查权重填写");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function saveStepTwo() {
|
|
|
try {
|
|
|
syncChannelNames();
|
|
|
const result = await validateSupplierEvaluationStepTwo(buildStepTwoPayload());
|
|
|
if (!result?.valid) throw new Error(result?.message || "渠道评分校验未通过");
|
|
|
smallBatchRows.value = createScoreRows(result.smallBatchChannels);
|
|
|
largeBatchRows.value = createScoreRows(result.largeBatchChannels);
|
|
|
await saveStudentTrainingAnswer({ taskKey: TASK_KEY, currentStep: 2, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step2Answer: JSON.stringify({ weights: result.weights, smallBatchChannels: result.smallBatchChannels, largeBatchChannels: result.largeBatchChannels }) });
|
|
|
stepTwoSaved.value = true;
|
|
|
syncChannelNames();
|
|
|
ElMessage.success("渠道评分已保存,可进入第 3 步形成采购建议");
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查渠道和评分");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function saveStepThree() {
|
|
|
try {
|
|
|
const result = await validateSupplierEvaluationStepThree(buildStepThreePayload());
|
|
|
if (!result?.valid) throw new Error(result?.message || "第 3 步校验未通过");
|
|
|
const step3Answer = { scenarioAnalyses: result.scenarioAnalyses, procurementRecommendation: result.procurementRecommendation, risks: result.risks };
|
|
|
await saveStudentTrainingAnswer({ taskKey: TASK_KEY, currentStep: 3, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step3Answer: JSON.stringify(step3Answer) });
|
|
|
applyStepThree({ step3Answer });
|
|
|
ElMessage.success("采购建议与供应风险已保存");
|
|
|
} catch (error) {
|
|
|
ElMessage.error(error?.message || "保存失败,请检查填写内容");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
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 scoped>
|
|
|
.supplier-page { min-height: 100%; padding: 24px; color: #dce9f2; background: #06111d; }
|
|
|
.workspace-shell { box-sizing: border-box; width: min(100%, 1520px); margin: 0 auto; padding: 28px; border: 1px solid rgba(0, 180, 255, .25); border-radius: 36px; background: rgba(8, 18, 28, .6); box-shadow: inset 0 1px 0 rgba(169, 229, 255, .08), 0 22px 48px rgba(0, 0, 0, .22); }
|
|
|
.supplier-page .training-core { box-sizing: border-box; min-width: 0; width: 100%; margin: 0; padding: 28px; border: 1px solid rgba(0, 180, 255, .25); border-radius: 36px; background: rgba(8, 18, 28, .6); box-shadow: inset 0 1px 0 rgba(169, 229, 255, .08), 0 22px 48px rgba(0, 0, 0, .22); }
|
|
|
.supplier-page .workspace-shell { width: 100%; max-width: none; margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
|
|
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }.workspace-header__actions { display: flex; align-items: center; gap: 10px; }
|
|
|
.eyebrow, .step-caption { margin: 0 0 7px; color: #63dcff; font-weight: 700; letter-spacing: .1em; }
|
|
|
.workspace-header h1, .intro-card h2 { margin: 0; color: #fff; }.workspace-header h1 { margin-top: 12px; font-size: 30px; font-weight: 900; line-height: 1.3; }
|
|
|
.workspace-header p:not(.eyebrow), .intro-card > p:last-child { margin: 10px 0 0; color: #9db4c4; line-height: 1.75; }
|
|
|
.task-badge { display: inline-flex; align-items: center; min-height: 28px; padding: 4px 14px; border: 1px solid rgba(99, 217, 255, .55); border-radius: 999px; color: #dff5ff; background: rgba(17, 126, 178, .36); white-space: nowrap; }
|
|
|
.step-nav { display: grid; grid-template-columns: minmax(0, 1fr) 40px minmax(0, 1fr) 40px minmax(0, 1fr); align-items: center; gap: 8px; margin: 0 0 30px; padding: 18px 22px; border: 1px solid rgba(0, 180, 255, .3); border-radius: 22px; background: rgba(1, 18, 31, .62); }
|
|
|
.step-nav i { position: relative; height: 2px; background: rgba(86, 198, 235, .48); }.step-nav i::after { position: absolute; top: 50%; right: 0; width: 7px; height: 7px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }
|
|
|
.step-tab { display: flex; align-items: center; min-width: 0; min-height: 66px; gap: 10px; padding: 8px 12px; border: 1px solid rgba(68, 141, 177, .35); border-radius: 12px; color: #9ab3d0; background: rgba(3, 27, 43, .52); cursor: pointer; font: inherit; text-align: left; transition: .2s ease; }
|
|
|
.step-tab:hover { border-color: rgba(89, 223, 255, .82); color: #fff; background: rgba(8, 92, 131, .52); transform: translateY(-1px); }.step-tab:focus-visible { outline: 2px solid #72e8ff; outline-offset: 3px; }
|
|
|
.step-marker { display: grid; flex: 0 0 38px; width: 38px; height: 38px; place-items: center; border: 1px solid rgba(101, 176, 208, .6); border-radius: 50%; color: #a6c4d9; background: rgba(3, 25, 40, .8); font-size: 12px; font-weight: 900; }.step-copy { display: grid; min-width: 0; gap: 4px; }.step-copy b { overflow: hidden; font-size: 14px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }.step-copy small { color: #66b6d4; font-size: 10px; font-weight: 800; letter-spacing: .06em; }
|
|
|
.step-tab.active { color: white; border-color: #53d8fb; background: linear-gradient(135deg, rgba(7, 156, 207, .85), rgba(13, 105, 161, .92)); box-shadow: inset 0 0 24px rgba(77, 213, 255, .22); }.step-tab.active .step-marker { border-color: #72e8ff; color: #fff; background: linear-gradient(135deg, #08b7e8, #1375d0); box-shadow: 0 0 0 5px rgba(25, 179, 237, .14), 0 0 18px rgba(46, 208, 255, .58); }.step-tab.complete { color: #dffbff; }.step-tab.complete .step-marker { border-color: rgba(84, 230, 221, .85); color: #062b37; background: #6ef0e1; }
|
|
|
.intro-card, .analysis-panel { padding: 24px; border: 1px solid rgba(82, 174, 226, .42); border-radius: 26px; background: rgba(0, 25, 42, .64); }
|
|
|
.intro-card.compact { margin-bottom: 20px; }
|
|
|
.weights-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 20px; }
|
|
|
.weight-card, .score-section { padding: 20px; border: 1px solid rgba(71, 167, 209, .28); border-radius: 14px; background: rgba(4, 23, 36, .68); }
|
|
|
.card-title, .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
|
|
.card-title span, .section-heading span { color: #52cef6; font-size: 13px; font-weight: 700; letter-spacing: .07em; }
|
|
|
.card-title h3, .section-heading h3 { margin: 4px 0 0; color: #fff; font-size: 20px; }
|
|
|
.card-title strong { color: #6de4ff; font-size: 25px; }.card-title strong.danger { color: #ff8888; }
|
|
|
.training-table { width: 100%; border-collapse: collapse; table-layout: fixed; overflow: hidden; border: 1px solid #2b5c73; }
|
|
|
.training-table th { padding: 13px 10px; color: #effbff; background: #0c91c8; font-weight: 700; }
|
|
|
.training-table td { padding: 10px; border: 1px solid #2b5c73; color: #cfe2ec; vertical-align: middle; text-align: center; }
|
|
|
.training-table tbody tr:nth-child(even) { background: rgba(15, 48, 66, .42); }
|
|
|
.training-table :deep(.el-input__wrapper), .training-table :deep(.el-textarea__inner), .training-table :deep(.el-select__wrapper) { background: rgba(2, 15, 24, .7); box-shadow: 0 0 0 1px #315f75 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: #e7f6fd; }
|
|
|
.training-table :deep(.el-textarea__inner) { min-height: 70px !important; resize: vertical; }
|
|
|
.page-actions { display: flex; justify-content: center; padding-top: 28px; }.split-actions { justify-content: space-between; }
|
|
|
.page-actions :deep(.el-button--primary), .row-actions :deep(.el-button) { border-color: #18b6ec; background: #0e96ca; }
|
|
|
.score-section { margin-bottom: 20px; }.section-heading small { color: #93adbd; }.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%; }.total-score { color: #6de4ff !important; font-size: 17px; font-weight: 700; }
|
|
|
.analysis-panel + .analysis-panel { margin-top: 20px; }.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: #71dcfb !important; font-weight: 700; }.rank-result { display: flex; flex-direction: column; gap: 5px; }.rank-result strong { color: #f3fbff; }.rank-result span { color: #62cef1; font-size: 13px; }
|
|
|
.recommendation-card { margin-top: 20px; }.recommendation-card label { display: block; margin-bottom: 9px; color: #e7f5fb; font-weight: 700; }.recommendation-card :deep(.el-textarea__inner) { min-height: 130px !important; color: #e7f6fd; background: rgba(2, 15, 24, .7); box-shadow: 0 0 0 1px #315f75 inset; }
|
|
|
.risk-heading { align-items: flex-end; }.row-actions { display: flex; gap: 10px; }.panel-tip { margin: -4px 0 16px; color: #9db8c6; line-height: 1.7; }.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: #b7cbd6; font-style: italic; }
|
|
|
@media (max-width: 980px) { .weights-grid { grid-template-columns: 1fr; }.step-nav { grid-template-columns: 1fr; gap: 8px; }.step-nav i { display: none; }.workspace-header { flex-direction: column; }.risk-heading { align-items: flex-start; flex-direction: column; }.split-actions { gap: 12px; }.supplier-page { padding: 12px; }.workspace-shell { padding: 16px; } }
|
|
|
.step-tab.active { background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64)); }
|
|
|
.step-connector { position: relative; height: 2px; overflow: visible; background: rgba(86, 198, 235, .48); }
|
|
|
.step-connector::after { position: absolute; top: 50%; right: -1px; width: 7px; height: 7px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }
|
|
|
</style>
|