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

1294 lines
47 KiB
Vue

<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>
<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 class="score-section">
<div class="section-heading">
<div><span>渠道评分</span><h3>供应渠道评分与加权对比</h3></div>
<small>试产加权总分 = Σ(评分 × 试产权重) · 量产加权总分 = Σ(评分 × 量产权重)</small>
</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>
4 months ago
</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>
4 months ago
</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 },
4 months ago
]);
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家备选供应商,尝试引入新供应商,每季度询价,研判原料价格走势提前备货" },
4 months ago
];
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,
};
4 months ago
});
}
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() {
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 {
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 步形成采购建议");
} 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;
4 months ago
}
riskRows.value.push({ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, dimension: "", description: "", level: "中", measure: "", locked: false });
4 months ago
}
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-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 {
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: 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: 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;
}
.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%; }
/* 合并评分表:双列组布局 */
.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;
}
.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">
/* ============================================================
* 教育蓝极简换肤 · 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 {
flex: 1 1 0 !important;
max-width: 240px !important;
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: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: normal !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: 12px !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: 13px !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: 13px !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;
}
}
/* ============================================================
* 底部步骤按钮(el-button)
* ============================================================ */
.supplier-page .page-actions .el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !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: 13px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease !important;
}
.supplier-page .page-actions .el-button:hover:not(.is-disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
/* 主按钮(el-button--primary) */
.supplier-page .page-actions .el-button--primary {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
.supplier-page .page-actions .el-button--primary:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !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;
}
}
4 months ago
</style>