|
|
|
|
@ -94,26 +94,37 @@
|
|
|
|
|
<section class="intro-card compact">
|
|
|
|
|
<p class="step-caption">Step 02</p>
|
|
|
|
|
<h2>为供应渠道评分并比较结果</h2>
|
|
|
|
|
<p>每项评分为 0–10 分,渠道名称和评分均由学生填写;系统会按第一步已保存的权重自动计算加权总分。</p>
|
|
|
|
|
<p>每项评分为 0–10 分,渠道名称与评分由学生填写;系统按第一步已保存的试产/量产权重分别计算两个加权总分。</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-for="section in scoreSections" :key="section.key" class="score-section">
|
|
|
|
|
<section class="score-section">
|
|
|
|
|
<div class="section-heading">
|
|
|
|
|
<div><span>{{ section.label }}</span><h3>{{ section.title }}</h3></div>
|
|
|
|
|
<small>加权总分 = Σ(维度评分 × 权重)</small>
|
|
|
|
|
<div><span>渠道评分</span><h3>供应渠道评分与加权对比</h3></div>
|
|
|
|
|
<small>试产加权总分 = Σ(评分 × 试产权重) · 量产加权总分 = Σ(评分 × 量产权重)</small>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="table-scroll">
|
|
|
|
|
<table class="training-table score-table">
|
|
|
|
|
<table class="training-table score-table merged-score-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr><th>渠道</th><th v-for="dimension in rows" :key="dimension.key">{{ dimension.name }}</th><th>加权总分</th></tr>
|
|
|
|
|
<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 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">
|
|
|
|
|
<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">{{ weightedScore(row, section.weights) }}</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>
|
|
|
|
|
@ -268,15 +279,18 @@ function normalizeScores(source = {}) {
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createScoreRows(savedRows = []) {
|
|
|
|
|
const source = Array.isArray(savedRows) ? savedRows : [];
|
|
|
|
|
function createChannelRows(smallSaved = [], largeSaved = []) {
|
|
|
|
|
const small = Array.isArray(smallSaved) ? smallSaved : [];
|
|
|
|
|
const large = Array.isArray(largeSaved) ? largeSaved : [];
|
|
|
|
|
return Array.from({ length: 5 }, (_, index) => {
|
|
|
|
|
const saved = source[index] || {};
|
|
|
|
|
const s = small[index] || {};
|
|
|
|
|
const l = large[index] || {};
|
|
|
|
|
return {
|
|
|
|
|
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
|
|
|
|
|
channel: String(saved.channel || ""),
|
|
|
|
|
scores: normalizeScores(saved.scores),
|
|
|
|
|
weightedTotal: saved.weightedTotal ?? null,
|
|
|
|
|
channel: String(s.channel || l.channel || ""),
|
|
|
|
|
scores: normalizeScores(s.scores ?? l.scores),
|
|
|
|
|
smallWeightedTotal: s.weightedTotal ?? null,
|
|
|
|
|
largeWeightedTotal: l.weightedTotal ?? null,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
@ -302,8 +316,7 @@ function createRiskRows(savedRows = []) {
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const smallBatchRows = ref(createScoreRows());
|
|
|
|
|
const largeBatchRows = ref(createScoreRows());
|
|
|
|
|
const channelRows = ref(createChannelRows());
|
|
|
|
|
const scenarioAnalyses = ref(createScenarioAnalyses());
|
|
|
|
|
const procurementRecommendation = ref("");
|
|
|
|
|
const riskRows = ref(createRiskRows());
|
|
|
|
|
@ -312,16 +325,12 @@ const smallWeightTotal = computed(() => rows.value.reduce((sum, row) => sum + Nu
|
|
|
|
|
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; },
|
|
|
|
|
set score(value) { row.scores[key] = value; row.smallWeightedTotal = null; row.largeWeightedTotal = null; },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -330,18 +339,18 @@ function toNumber(value) {
|
|
|
|
|
return Number.isFinite(numeric) ? numeric : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function weightedScore(row, weights) {
|
|
|
|
|
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 source = scenario === "smallBatch" ? smallBatchRows.value : largeBatchRows.value;
|
|
|
|
|
const weights = scenario === "smallBatch" ? smallWeights.value : largeWeights.value;
|
|
|
|
|
return source
|
|
|
|
|
const storedKey = scenario === "smallBatch" ? "smallWeightedTotal" : "largeWeightedTotal";
|
|
|
|
|
return channelRows.value
|
|
|
|
|
.map((row) => {
|
|
|
|
|
const stored = Number(row.weightedTotal);
|
|
|
|
|
return { channel: String(row.channel || "").trim(), score: Number.isFinite(stored) ? stored : Number(weightedScore(row, weights)) };
|
|
|
|
|
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"));
|
|
|
|
|
@ -356,12 +365,6 @@ function formatScore(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) => ({
|
|
|
|
|
@ -373,16 +376,17 @@ function buildStepOnePayload() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildStepTwoPayload() {
|
|
|
|
|
const serializeChannels = (source) => source.map((row) => ({
|
|
|
|
|
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(smallBatchRows.value),
|
|
|
|
|
largeBatchChannels: serializeChannels(largeBatchRows.value),
|
|
|
|
|
smallBatchChannels: serializeChannels("smallWeightedTotal"),
|
|
|
|
|
largeBatchChannels: serializeChannels("largeWeightedTotal"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -421,9 +425,7 @@ function applyStepOne(answer) {
|
|
|
|
|
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();
|
|
|
|
|
channelRows.value = createChannelRows(result.smallBatchChannels, result.largeBatchChannels);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -451,14 +453,11 @@ async function saveStepOne() {
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
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;
|
|
|
|
|
syncChannelNames();
|
|
|
|
|
ElMessage.success("渠道评分已保存,可进入第 3 步形成采购建议");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
ElMessage.error(error?.message || "保存失败,请检查渠道和评分");
|
|
|
|
|
@ -878,6 +877,35 @@ onMounted(async () => {
|
|
|
|
|
.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);
|
|
|
|
|
|