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

1083 lines
40 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">
<span class="task-badge"><el-icon><DataAnalysis /></el-icon> 核心实训 · 供应渠道评估</span>
<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>
<div class="supplier-toolbar">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
</div>
</section>
<section class="supplier-workbench">
<nav class="step-indicator" aria-label="供应渠道评估步骤">
<button type="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"><strong class="step-name">设置评估权重</strong></span>
</button>
<button type="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"><strong class="step-name">渠道评分与对比</strong></span>
</button>
<button type="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"><strong class="step-name">采购建议与风险</strong></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, DataAnalysis, 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 { 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 briefOpen = 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: 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 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()))?.data || {};
if (!result?.valid) throw new Error(result?.message || "权重校验未通过");
await saveStudentTrainingAnswer(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()))?.data || {};
if (!result?.valid) throw new Error(result?.message || "渠道评分校验未通过");
smallBatchRows.value = createScoreRows(result.smallBatchChannels);
largeBatchRows.value = createScoreRows(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;
syncChannelNames();
ElMessage.success("渠道评分已保存,可进入第 3 步形成采购建议");
} catch (error) {
ElMessage.error(error?.message || "保存失败,请检查渠道和评分");
}
}
async function saveStepThree() {
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 || "保存失败,请检查填写内容");
}
}
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: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
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 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.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: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.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: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
.supplier-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
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);
}
.card-title, .section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.card-title span, .section-heading span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 700;
letter-spacing: 0.07em;
}
.card-title h3, .section-heading 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;
}
/* ===== 操作按钮区 ===== */
.page-actions {
display: flex;
justify-content: center;
padding-top: 8px;
}
.split-actions { justify-content: space-between; }
.page-actions :deep(.el-button),
.row-actions :deep(.el-button) {
height: 40px;
padding: 0 18px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
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;
}
.page-actions :deep(.el-button:hover),
.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);
}
.page-actions :deep(.el-button--primary),
.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);
}
.page-actions :deep(.el-button--primary:hover),
.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);
}
/* ===== score-section / analysis-panel ===== */
.score-section { margin-bottom: 10px; }
.section-heading small { color: var(--student-text-secondary); font-size: var(--student-fs-body-sm); }
.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: 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;
}
.risk-heading { align-items: flex-end; }
.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; }
.supplier-toolbar { width: 100%; justify-content: flex-start; }
.risk-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.split-actions { gap: 12px; flex-wrap: wrap; }
}
</style>
<style lang="scss">
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.supplier-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
}
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
/* 未到达项 hover */
.step-tab:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成 */
.step-tab.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤 */
.step-tab.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
/* ===== 窄屏:900px 横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.supplier-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.supplier-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
</style>