feat: improve training evaluation workflow

bugfix/ai-assessment-evidence-rescore
chenyuan 1 month ago
parent f73765306f
commit e28459833d

@ -304,8 +304,8 @@ export function validateReleaseChannelEvaluationStepTwo(payload) {
&& [row.dailySearchVolume, row.supplyDemandRatio, row.averageCpc, row.existingVisitorCount]
.every((value) => Number.isFinite(value) && value >= 0)
&& [row.averageClickRate, row.averageConversionRate].every((value) => Number.isFinite(value) && value >= 0 && value <= 100)
&& Number.isInteger(row.matchingScore) && row.matchingScore >= 1 && row.matchingScore <= 5);
if (!valid) return Promise.reject(new Error("请完整填写渠道数据;点击率和转化率为0-100%,匹配度为1-5分"));
&& Number.isInteger(row.matchingScore) && row.matchingScore >= 1 && row.matchingScore <= 100);
if (!valid) return Promise.reject(new Error("请完整填写渠道数据;点击率和转化率为0-100%,匹配度为1-100分"));
return Promise.resolve({ code: 200, data: { valid: true, message: "渠道数据校验通过", rowCount: normalizedRows.length } });
}
return request({
@ -332,6 +332,8 @@ export function validateReleaseChannelEvaluationStepThree(payload) {
];
if (!sourceRows.length || rows.length !== sourceRows.length) throw new Error("请先完成第二步全部渠道数据");
const submitted = new Map(rows.map((row) => [String(row?.channel || "").trim(), row]));
const incorrectItems = [];
let totalItemCount = 0;
for (const source of sourceRows) {
const channel = String(source?.channel || "").trim();
const row = submitted.get(channel);
@ -346,12 +348,16 @@ export function validateReleaseChannelEvaluationStepThree(payload) {
const max = Math.max(...values);
const expectedScore = max === min ? 10 : Number((((reverse ? max - value : value - min) * 10) / (max - min)).toFixed(1));
const supplied = dimensions.get(dimension);
if (!supplied || Number(supplied.weight) !== weight || Number(supplied.score) !== expectedScore) throw new Error(`“${channel}”的“${dimension}”得分或权重不正确`);
if (!supplied || Number(supplied.weight) !== weight) throw new Error(`“${channel}”的“${dimension}”权重不完整`);
totalItemCount += 1;
if (Number(supplied.score) !== expectedScore) incorrectItems.push(`“${channel}”的“${dimension}”得分`);
expectedTotal += expectedScore * weight / 100;
});
if (Number(row.weightedScore) !== Number(expectedTotal.toFixed(2))) throw new Error(`“${channel}”的加权得分不正确`);
totalItemCount += 1;
if (Number(row.weightedScore) !== Number(expectedTotal.toFixed(2))) incorrectItems.push(`“${channel}”的加权得分`);
}
return Promise.resolve({ code: 200, data: { valid: true, message: "归一化得分与加权得分校验通过", rowCount: rows.length } });
const calculationCorrect = incorrectItems.length === 0;
return Promise.resolve({ code: 200, data: { valid: true, calculationCorrect, totalItemCount, incorrectItemCount: incorrectItems.length, incorrectItems, message: calculationCorrect ? "计算结果已校验正确,可进入下一步" : `已保存并进入下一步。系统发现${incorrectItems.length}项计算结果与参考值不一致,AI助评时将据此扣分。`, rowCount: rows.length } });
} catch (error) {
return Promise.reject(error);
}

@ -38,7 +38,7 @@
<div class="category-table-wrap">
<table class="category-table">
<thead><tr><th>品类名称</th><th>近12个月营收(万元)</th><th>营收占比(%)</th><th>累计占比(%)</th><th>库存资金占用(万元)</th><th>资金占用率</th><th>近30天动销率</th><th>毛利率</th></tr></thead>
<thead><tr><th>品类名称</th><th>近12个月营收(万元)</th><th>营收占比(%)</th><th>累计占比(%)</th><th>库存资金占用(万元)</th><th>资金占用率(%)</th><th>近30天动销率(%)</th><th>毛利率</th></tr></thead>
<tbody>
<tr v-for="(row, index) in categoryRows" :key="row.id">
<td><el-input v-model="row.categoryName" :aria-label="`第${index + 1}行品类名称`" /></td>
@ -46,8 +46,8 @@
<td><el-input v-model="row.revenueShare" inputmode="decimal" :aria-label="`第${index + 1}行营收占比`" /></td>
<td><el-input v-model="row.cumulativeShare" inputmode="decimal" :aria-label="`第${index + 1}行累计占比`" /></td>
<td><el-input v-model="row.inventoryCapital" inputmode="decimal" :aria-label="`第${index + 1}行库存资金占用`" /></td>
<td><el-input v-model="row.capitalOccupancyRate" inputmode="decimal" :aria-label="`第${index + 1}行资金占用率`" /></td>
<td><el-input v-model="row.sellThroughRate" inputmode="decimal" :aria-label="`第${index + 1}行近30天动销率`" /></td>
<td><el-input-number v-model="row.capitalOccupancyRate" :min="0" :max="100" :precision="2" :step="0.01" :controls="false" inputmode="decimal" :aria-label="`第${index + 1}行资金占用率(百分数)`" /></td>
<td><el-input-number v-model="row.sellThroughRate" :min="0" :max="100" :precision="2" :step="0.01" :controls="false" inputmode="decimal" :aria-label="`第${index + 1}行近30天动销率(百分数)`" /></td>
<td><el-input v-model="row.grossMargin" inputmode="decimal" :aria-label="`第${index + 1}行毛利率`" /></td>
</tr>
<tr v-if="!categoryRows.length"><td colspan="8" class="empty-cell">暂无数据,请根据案例资料增加行并填写。</td></tr>
@ -226,7 +226,13 @@ const selectedProduct = computed(() => categoryRows.value.find((row) => row.cate
const currentClassificationLabel = computed(() => selectedProduct.value?.abcClassification ? `${selectedProduct.value.abcClassification}类${selectedProduct.value.abcClassification === "A" ? "核心品类" : selectedProduct.value.abcClassification === "B" ? "潜力品类" : "长尾品类"}` : "选择新品后自动带出");
const progressItems = computed(() => configuredSteps.value.map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
function createRow(source = {}) { return { id: source.id || `${Date.now()}-${Math.random().toString(16).slice(2)}`, categoryName: source.categoryName ?? "", revenue: source.revenue ?? "", revenueShare: source.revenueShare ?? "", cumulativeShare: source.cumulativeShare ?? "", inventoryCapital: source.inventoryCapital ?? "", capitalOccupancyRate: source.capitalOccupancyRate ?? "", sellThroughRate: source.sellThroughRate ?? "", grossMargin: source.grossMargin ?? "", abcClassification: source.abcClassification ?? "" }; }
function normalizePercentageValue(value) {
const text = String(value ?? "").trim().replace(/%/g, "");
if (!text) return null;
const number = Number(text);
return Number.isFinite(number) ? Number(number.toFixed(2)) : null;
}
function createRow(source = {}) { return { id: source.id || `${Date.now()}-${Math.random().toString(16).slice(2)}`, categoryName: source.categoryName ?? "", revenue: source.revenue ?? "", revenueShare: source.revenueShare ?? "", cumulativeShare: source.cumulativeShare ?? "", inventoryCapital: source.inventoryCapital ?? "", capitalOccupancyRate: normalizePercentageValue(source.capitalOccupancyRate), sellThroughRate: normalizePercentageValue(source.sellThroughRate), grossMargin: source.grossMargin ?? "", abcClassification: source.abcClassification ?? "" }; }
function createStepFour(source = {}) { return { productName: source.productName ?? "", matrixQuadrant: source.matrixQuadrant ?? "", matrixReason: source.matrixReason ?? "", necessityAnalysis: source.necessityAnalysis ?? "", recommendedClassification: source.recommendedClassification ?? "", trafficSupportMeasures: source.trafficSupportMeasures ?? "" }; }
function createStrategyRows(savedRows = []) { const saved = new Map((Array.isArray(savedRows) ? savedRows : []).map((row) => [row?.layer, row])); return [{ layer: "A", label: "A类核心品类", operationPlaceholder: "例如:重点投入流量与推广预算,持续优化转化率", inventoryPlaceholder: "例如:安全库存上限,高频补货,优先保障供货稳定" }, { layer: "B", label: "B类潜力品类", operationPlaceholder: "例如:开展AB测试,针对潜力品类配置流量扶持", inventoryPlaceholder: "例如:密切关注运营效果,活动前适当备货" }, { layer: "C", label: "C类长尾品类", operationPlaceholder: "例如:控制流量预算,通过组合活动逐步清退滞销SKU", inventoryPlaceholder: "例如:停止主动补货,清退滞销SKU" }, { layer: "NEW", label: "新产品", operationPlaceholder: "例如:设置新品测试活动,重点推广运营", inventoryPlaceholder: "例如:提前备货,根据渠道测试情况重点备货" }].map((row) => ({ ...row, operationStrategy: saved.get(row.layer)?.operationStrategy ?? "", inventoryStrategy: saved.get(row.layer)?.inventoryStrategy ?? "", resourcePriority: saved.get(row.layer)?.resourcePriority ?? "" })); }
function normalizeActiveStep(value) { return !configuredSteps.value.length ? 1 : Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
@ -378,7 +384,7 @@ onMounted(async () => {
.training-core { min-width:0; padding:28px; border:1px solid var(--line); border-radius:28px; background:rgba(5,25,37,.82); box-shadow:0 22px 48px rgba(0,0,0,.22); }.task-header{margin-bottom:20px}.task-badge{display:inline-flex;align-items:center;gap:8px;padding:5px 12px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.3);font-size:12px;font-weight:800}.task-header h1{margin:10px 0 0;font-size:30px}.task-card{margin-top:24px;overflow:hidden;border:1px solid var(--line);border-radius:20px;background:rgba(3,22,34,.78)}
.workbench-head{display:flex;justify-content:space-between;gap:18px;padding:22px 24px;border-bottom:1px solid var(--line)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800;letter-spacing:.12em}.workbench-head h2{margin:5px 0 0;font-size:24px}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.workbench-actions{justify-content:flex-end}.outline-action,.action-button,.nav-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;padding:0 16px;border:1px solid rgba(99,217,255,.5);border-radius:9px;color:#e9fbff;background:rgba(17,126,178,.34);font:inherit;font-weight:700;cursor:pointer}.outline-action:hover,.action-button:hover,.nav-button:hover{border-color:#7deaff;background:rgba(20,148,193,.48)}button:disabled{cursor:not-allowed;opacity:.45}
.step-indicator{display:flex;gap:9px;padding:16px 24px;border-bottom:1px solid var(--line);overflow-x:auto}.step-tab{display:inline-flex;align-items:center;gap:8px;min-width:max-content;padding:8px 12px;border:1px solid var(--line);border-radius:999px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.step-tab span{display:grid;width:21px;height:21px;place-items:center;border-radius:50%;color:#bdefff;background:rgba(16,117,154,.35);font-size:11px;font-weight:800}.step-tab.active{border-color:#71dfff;color:white;background:rgba(12,114,159,.48)}.step-tab.complete{color:#d5fff8}.step-tab.complete span{background:#2ba995}
.category-step{padding:24px}.instruction{margin:0 0 20px;color:#d8edf5;font-size:15px;line-height:1.75}.table-toolbar{display:flex;justify-content:space-between;gap:14px;margin-bottom:12px;color:var(--muted);font-size:13px}.action-button--muted{border-color:rgba(158,192,207,.35);background:rgba(75,101,112,.25)}.category-table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}.category-table{width:100%;min-width:1260px;border-collapse:collapse}.category-table th,.category-table td{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);text-align:center}.category-table th{color:#e6f9ff;background:rgba(23,112,150,.42);font-size:13px;white-space:nowrap}.category-table td{background:rgba(2,18,28,.32)}.category-table tr:last-child td{border-bottom:0}.category-table th:last-child,.category-table td:last-child{border-right:0}.empty-cell{height:88px;color:var(--muted)}.abc-table{min-width:900px}.abc-table :deep(.el-select){width:100%;min-width:180px}
.category-step{padding:24px}.instruction{margin:0 0 20px;color:#d8edf5;font-size:15px;line-height:1.75}.table-toolbar{display:flex;justify-content:space-between;gap:14px;margin-bottom:12px;color:var(--muted);font-size:13px}.action-button--muted{border-color:rgba(158,192,207,.35);background:rgba(75,101,112,.25)}.category-table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}.category-table{width:100%;min-width:1260px;border-collapse:collapse}.category-table th,.category-table td{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);text-align:center}.category-table th{color:#e6f9ff;background:rgba(23,112,150,.42);font-size:13px;white-space:nowrap}.category-table td{background:rgba(2,18,28,.32)}.category-table tr:last-child td{border-bottom:0}.category-table th:last-child,.category-table td:last-child{border-right:0}.category-table :deep(.el-input-number){width:100%}.empty-cell{height:88px;color:var(--muted)}.abc-table{min-width:900px}.abc-table :deep(.el-select){width:100%;min-width:180px}
.rule-notice{margin-top:20px;padding:16px 18px;border:1px solid rgba(255,112,112,.46);border-radius:12px;background:rgba(126,31,35,.15);color:#ffd5d5}.rule-notice strong{color:#ff9292}.rule-notice p{margin:8px 0 0;line-height:1.65}.analysis-step{padding:24px}.analysis-tabs{display:flex;gap:10px;margin-bottom:18px}.analysis-tabs button{padding:9px 14px;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.analysis-tabs button.active{color:white;border-color:#71dfff;background:rgba(12,114,159,.48)}.business-workspace{overflow:hidden;border:1px solid var(--line);border-radius:14px}.step-navigation{justify-content:center;padding:20px 24px 0}.nav-button--save,.nav-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}.training-actions{justify-content:center;gap:28px;padding:26px 24px}.training-actions :deep(.training-action){min-width:160px;height:50px;border-color:#2c83aa;color:#dff7ff;background:rgba(13,64,88,.62);font-size:17px;font-weight:800}
.pareto-upload-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;min-height:330px;padding:26px;border:1px dashed rgba(113,223,255,.7);border-radius:16px;background:linear-gradient(135deg,rgba(5,47,69,.7),rgba(4,23,35,.58));text-align:center}.file-input{display:none}.upload-icon{font-size:42px;color:#71dfff}.pareto-upload-panel>strong{font-size:20px}.pareto-upload-panel>span,.upload-file-meta span{color:var(--muted);font-size:13px}.pareto-preview{width:min(100%,760px);height:270px;border:1px solid var(--line);border-radius:10px;background:rgba(0,10,18,.65);cursor:zoom-in}.upload-file-meta{display:grid;gap:5px;max-width:100%;word-break:break-all}.pareto-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.danger-action{border-color:rgba(255,145,145,.6);color:#ffd7d7;background:rgba(130,43,43,.23)}
.pareto-materials{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-top:20px;padding:18px 20px;border-left:3px solid var(--accent);border-radius:0 14px 14px 0;background:rgba(11,79,109,.22);color:#d8edf5}.pareto-materials h3{margin:0 0 8px;font-size:16px}.pareto-materials ol{margin:0;padding-left:20px;line-height:1.8}.pareto-materials__actions{display:grid;flex:0 0 178px;gap:10px}.action-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}

@ -36,12 +36,31 @@
AI助评
</h3>
<div v-if="displayScoreVisible" class="score-badge">
<span>综合评分</span>
<strong>{{ displayFinalScore }}</strong>
<em>/ 100</em>
</div>
<div class="ai-output ai-output--review">
<div class="output-glow"></div>
<p>{{ displayEvalMessage }}</p>
<div v-if="displayScoreVisible" class="score-badge">
综合评分:<strong>{{ displayFinalScore }}</strong> / 100
<div v-if="hasStructuredAssessment" class="assessment-result">
<p v-if="reports.assessmentSummary" class="assessment-summary">{{ reports.assessmentSummary }}</p>
<div v-if="displayScoreCriteria.length" class="assessment-criteria" aria-label="评分明细">
<div v-for="item in displayScoreCriteria" :key="`${item.name}-${item.score}`" class="assessment-criterion">
<div class="assessment-criterion__heading">
<strong>{{ item.name }}</strong>
<span v-if="item.score !== ''">{{ item.score }}<small v-if="item.maxScore !== ''"> / {{ item.maxScore }}</small></span>
</div>
<p v-if="item.description">{{ item.description }}</p>
</div>
</div>
<div v-if="reports.improvements.length" class="assessment-actions">
<strong>优先改进</strong>
<ul><li v-for="item in reports.improvements" :key="item">{{ item }}</li></ul>
</div>
</div>
<p v-else>{{ displayEvalMessage }}</p>
</div>
<p v-if="currentTaskKey && !hasSavedAnswer" class="assessment-hint">请先至少填写并保存一项实训内容,再请求 AI 助评。</p>
@ -109,13 +128,20 @@ const route = useRoute();
const currentTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const reports = computed(() => normalizeEvaluationReports(evaluation.value));
const currentStepContext = computed(() => [props.studyTip, props.referenceTitle, plainText(props.referenceText)].filter(Boolean).join("\n"));
const displayScoreCriteria = computed(() => {
const criteria = reports.value.scoreCriteria;
const stepNames = props.progressItems.map((item) => String(item?.text || "").trim()).filter(Boolean);
if (!stepNames.length || stepNames.length !== criteria.length) return criteria;
return criteria.map((item, index) => ({ ...item, name: stepNames[index] }));
});
const displayAssistMessage = computed(() => assistMessage.value || props.studyTip);
const displayScoreVisible = computed(() => props.scoreVisible || internalScoreVisible.value || reports.value.score !== null);
const displayFinalScore = computed(() => (props.scoreVisible ? props.finalScore : reports.value.score ?? internalScore.value));
const hasStructuredAssessment = computed(() => Boolean(reports.value.assessmentSummary || displayScoreCriteria.value.length || reports.value.improvements.length));
const displayEvalMessage = computed(() => {
if (props.scoreVisible) return props.evalMessage;
return reports.value.assessmentReport || internalEvalMessage.value || props.evalMessage;
return internalEvalMessage.value || props.evalMessage;
});
onMounted(loadEvaluation);
@ -174,7 +200,7 @@ async function requestEvaluation() {
await loadEvaluation();
internalScoreVisible.value = reports.value.score !== null;
internalScore.value = reports.value.score ?? "--";
internalEvalMessage.value = reports.value.assessmentReport || props.evalMessage;
internalEvalMessage.value = reports.value.assessmentSummary || "AI 评分已生成,请查看评分明细与改进建议。";
} catch (error) {
internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。";
} finally { assessmentLoading.value = false; }
@ -266,6 +292,61 @@ function plainText(value) {
margin: 0;
}
}
.ai-output--review {
max-height: 500px;
overflow-y: auto;
}
.assessment-result {
position: relative;
z-index: 1;
display: grid;
gap: 12px;
}
.assessment-summary {
margin: 0;
color: #e6f7ff;
font-weight: 650;
line-height: 1.8;
}
.assessment-criteria {
display: grid;
gap: 8px;
}
.assessment-criterion {
padding: 10px 11px;
border: 1px solid rgba(113, 225, 255, 0.22);
border-radius: 10px;
background: rgba(10, 43, 61, 0.4);
}
.assessment-criterion__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #f0fbff;
}
.assessment-criterion__heading span {
flex: 0 0 auto;
color: #71e8ff;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.assessment-criterion__heading small { color: #91bbcf; font-weight: 600; }
.assessment-criterion p {
margin: 6px 0 0;
color: #afd3e3;
font-size: 13px;
line-height: 1.65;
}
.assessment-actions {
padding: 10px 11px;
border-left: 2px solid #5ce4ff;
color: #cceafa;
background: rgba(29, 114, 149, 0.14);
}
.assessment-actions strong { color: #7cecff; font-size: 13px; }
.assessment-actions ul { margin: 6px 0 0; padding-left: 18px; }
.assessment-actions li + li { margin-top: 4px; }
.study-messages {
position: relative;
z-index: 1;
@ -380,17 +461,30 @@ function plainText(value) {
z-index: 1;
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 14px;
padding: 8px 12px;
border: 1px solid rgba(126, 233, 255, 0.38);
border-radius: 999px;
width: 100%;
gap: 8px;
margin: -2px 0 14px;
padding: 12px 14px;
border: 1px solid rgba(101, 238, 255, 0.7);
border-radius: 12px;
color: #dff8ff;
background: rgba(11, 42, 56, 0.78);
background:
linear-gradient(90deg, rgba(20, 169, 216, 0.4), rgba(8, 50, 70, 0.75)),
rgba(11, 42, 56, 0.78);
box-shadow: inset 0 1px 0 rgba(220, 253, 255, 0.23), 0 0 24px rgba(39, 209, 255, 0.16);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.04em;
span { color: #dff8ff; }
em { color: #b8dce9; font-size: 13px; font-style: normal; font-weight: 700; }
strong {
color: #7ee9ff;
font-size: 20px;
margin-left: auto;
font-size: 28px;
line-height: 1;
text-shadow: 0 0 14px rgba(102, 238, 255, 0.6);
}
}

@ -8,6 +8,11 @@ function safeParseJson(value) {
}
}
function criterionDisplayName(value) {
const name = String(value || "").trim();
return name || "评分项";
}
function formatReport(value) {
if (value == null) return "";
const parsed = typeof value === "string" ? safeParseJson(value) : value;
@ -24,13 +29,19 @@ function normalizeCriteria(value) {
return value.map((item) => {
if (typeof item === "string") return { name: item, score: "", description: "" };
return {
name: String(item?.name || item?.criterion || item?.title || "评分项"),
name: criterionDisplayName(item?.name || item?.criterion || item?.title),
score: item?.score ?? item?.weight ?? "",
description: String(item?.description || item?.detail || ""),
maxScore: item?.maxScore ?? item?.max ?? "",
description: String(item?.rationale || item?.description || item?.detail || ""),
};
});
}
function normalizeTextList(value) {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
function normalizeOperation(operation, root, type) {
const value = operation && typeof operation === "object" ? operation : {};
const prefix = type === "help" ? "help" : "assessment";
@ -47,15 +58,19 @@ function normalizeEvaluationReports(evaluation) {
const root = evaluation && typeof evaluation === "object" ? evaluation : {};
const help = normalizeOperation(root.help || root.helpEvaluation, root, "help");
const assessment = normalizeOperation(root.assessment || root.assessmentEvaluation, root, "assessment");
const assessmentReportData = safeParseJson(root.assessmentReportJson);
const assessmentReportValue = assessment.report || assessment.content || assessment.feedback;
const assessmentReportData = safeParseJson(root.assessmentReportJson) || safeParseJson(assessmentReportValue) || {};
return {
help,
assessment,
helpReport: formatReport(help.report || help.content || help.suggestion),
assessmentReport: formatReport(assessment.report || assessment.content || assessment.feedback),
assessmentReport: formatReport(assessmentReportValue),
score: root.assessmentScore ?? assessment.score ?? assessmentReportData?.score ?? root.score ?? null,
scoreCriteria: normalizeCriteria(assessmentReportData?.criteria || assessment.scoreCriteria || root.scoreCriteria),
assessmentSummary: String(assessmentReportData?.overallComment || assessmentReportData?.summary || "").trim(),
strengths: normalizeTextList(assessmentReportData?.strengths),
improvements: normalizeTextList(assessmentReportData?.improvements),
};
}

@ -56,7 +56,7 @@
<tfoot><tr><th colspan="2">当前权重合计</th><td :class="{ 'total-error': weightTotal !== 100 }">{{ displayWeightTotal }}%</td><td></td></tr></tfoot>
</table>
</div>
<p class="hint">默认展示建议的评估维度和权重占比。可修改任意单元格,也可逐行新增、删除;点击下一步时系统校验所有权重之和必须等于 100%。</p>
<p class="hint">默认展示建议的评估维度和建议权重范围,具体权重需由学生自行填写。可修改任意单元格,也可逐行新增、删除;点击下一步时系统校验所有权重之和必须等于 100%。</p>
<footer class="footer-actions">
<el-button :loading="saving" @click="saveWeightStep">保存当前进度</el-button>
<el-button type="primary" :loading="validating" @click="goStepTwo">下一步<el-icon><ArrowRight /></el-icon></el-button>
@ -72,7 +72,7 @@
</div>
<div class="table-wrap">
<table class="channel-table">
<thead><tr><th>渠道</th><th>日均总搜索量</th><th>供需比</th><th>平均点击率(%)</th><th>平均CPC(元)</th><th>平均转化率(%)</th><th>现有相关访客数</th><th>匹配度(1-5)</th><th class="operation-column">操作</th></tr></thead>
<thead><tr><th>渠道</th><th>日均总搜索量</th><th>供需比</th><th>平均点击率(%)</th><th>平均CPC(元)</th><th>平均转化率(%)</th><th>现有相关访客数</th><th>匹配度(1-100)</th><th class="operation-column">操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in channelRows" :key="row.id">
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
@ -82,7 +82,7 @@
<td><el-input-number v-model="row.averageCpc" :min="0" :precision="2" controls-position="right" /></td>
<td><el-input-number v-model="row.averageConversionRate" :min="0" :max="100" :precision="2" controls-position="right" /></td>
<td><el-input-number v-model="row.existingVisitorCount" :min="0" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.matchingScore" :min="1" :max="5" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.matchingScore" :min="1" :max="100" :precision="0" controls-position="right" /></td>
<td class="operation-column"><el-button link type="danger" @click="channelRows.splice(index, 1)">删除本行</el-button></td>
</tr>
<tr v-if="!channelRows.length"><td colspan="9" class="empty-row">暂无渠道数据,请增加一行后填写。</td></tr>
@ -99,6 +99,7 @@
<section v-else-if="activeStep === 3">
<p class="scoring-guide">通过 Min-Max 归一法将各维度原始数据转换为 0–10 分标准化得分。正向指标(搜索量、点击率、转化率、访客数、匹配度)按“渠道值 − 最小值”计算;逆向指标(供需比、CPC)按“最大值 − 渠道值”计算。请根据前两步数据手工填写得分与加权得分。</p>
<div class="table-actions"><el-button type="primary" plain @click="exportScoreRows"><el-icon><Download /></el-icon>导出</el-button></div>
<div class="table-wrap scoring-table-wrap">
<table class="scoring-table">
<thead>
@ -124,8 +125,8 @@
</tbody>
</table>
</div>
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;下一步会按已保存的前两步数据重新计算并校验。</p>
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。系统会校验加权得分,填写不正确不能进入下一步。</p>
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;系统会依据已保存的前两步数据检测计算结果。</p>
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。计算结果不一致时仍可进入下一步,系统会记录检测结果,供 AI 助评扣分参考。</p>
<section class="step-three-materials" aria-label="归一化评分参考资料">
<p>参考方法及工具如下:下载案例数据表,使用 Excel 按 Min-Max 公式计算各渠道归一化得分,可参考《实训操作说明》完成。</p>
<el-button type="primary" class="step-three-materials__button" @click="downloadPracticeGuide"><el-icon><Download /></el-icon>下载实训操作说明</el-button>
@ -186,8 +187,8 @@ const validating = ref(false);
let nextId = 1;
const makeRow = (values = {}) => ({ id: nextId++, ...values });
const defaultWeightRows = () => [
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", 20, "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", 15, "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", 15, "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", 15, "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", 15, "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", 10, "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", 10, "5~10"],
].map(([dimension, description, weight, suggestion]) => makeRow({ dimension, description, weight, suggestion }));
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", "5~10"],
].map(([dimension, description, suggestion]) => makeRow({ dimension, description, weight: undefined, suggestion }));
const defaultChannelRows = () => ["天猫", "京东", "抖音", "拼多多", "唯品会"].map((channel) => makeRow({ channel }));
const weightSuggestions = Object.fromEntries(defaultWeightRows().map((row) => [row.dimension, row.suggestion]));
const weightRows = ref(defaultWeightRows());
@ -215,6 +216,12 @@ const progressItems = computed(() => parseTrainingArray(taskConfig.value?.steps)
const weightPayload = () => ({ rows: weightRows.value.map(({ dimension, description, weight }) => ({ dimension, description, weight })) });
const channelPayload = () => ({ rows: channelRows.value.map(({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore }) => ({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore })) });
const scorePayload = () => ({ rows: scoreRows.value.map((row) => ({ channel: row.channel, weightedScore: row.weightedScore, dimensions: row.dimensions.map(({ dimension, score, weight }) => ({ dimension, score, weight })) })) });
const scoreAnswerPayload = (calculationCheck = {}) => ({ ...scorePayload(), calculationCheck: {
calculationCorrect: calculationCheck.calculationCorrect === true,
totalItemCount: Number(calculationCheck.totalItemCount) || 0,
incorrectItemCount: Number(calculationCheck.incorrectItemCount) || 0,
incorrectItems: Array.isArray(calculationCheck.incorrectItems) ? calculationCheck.incorrectItems : [],
} });
const rankingPayload = () => ({ rows: rankingRows.value.map(({ channel, weightedScore }) => ({ channel, weightedScore })), coreChannelAnalysis: coreChannelAnalysis.value });
function addWeightRow() { weightRows.value.push(makeRow({ dimension: "", description: "", weight: undefined, suggestion: "" })); }
function addChannelRow() { channelRows.value.push(makeRow({ channel: "" })); }
@ -226,7 +233,7 @@ async function validateChannelStep() { if (validating.value) return; validating.
function syncScoreRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); const weights = Object.fromEntries(weightRows.value.map((row) => [row.dimension, row.weight])); scoreRows.value = channelRows.value.map((channelRow) => { const saved = savedByChannel.get(channelRow.channel); const savedScores = new Map((saved?.dimensions || []).map((dimension) => [dimension?.dimension, dimension])); return makeRow({ channel: channelRow.channel, weightedScore: saved?.weightedScore, dimensions: scoringMetrics.map((metric) => makeRow({ key: metric.key, dimension: metric.dimension, weight: weights[metric.dimension], score: savedScores.get(metric.dimension)?.score })) }); }); }
function openStepThree() { syncScoreRows(scoreRows.value); activeStep.value = 3; }
async function saveScoreStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(3, { step3Answer: JSON.stringify(scorePayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepThree(scorePayload()); await saveAnswer(4, { step3Answer: JSON.stringify(scorePayload()) }); syncRankingRows(); activeStep.value = 4; ElMessage.success("归一化得分与加权得分校验通过,已进入第四步"); } catch (error) { ElMessage.error(error?.message || "请检查归一化得分和加权得分"); } finally { validating.value = false; } }
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { const result = await validateReleaseChannelEvaluationStepThree(scorePayload()); const calculationCheck = result?.data || {}; await saveAnswer(4, { step3Answer: JSON.stringify(scoreAnswerPayload(calculationCheck)) }); syncRankingRows(); activeStep.value = 4; ElMessage[calculationCheck.calculationCorrect ? "success" : "warning"](calculationCheck.message || "已保存并进入第四步"); } catch (error) { ElMessage.error(error?.message || "请完整填写归一化得分表"); } finally { validating.value = false; } }
function syncRankingRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); rankingRows.value = scoreRows.value.map((scoreRow) => { const saved = savedByChannel.get(scoreRow.channel); return makeRow({ channel: saved?.channel ?? "", weightedScore: saved?.weightedScore }); }); }
function openStepFour() { syncRankingRows(rankingRows.value); activeStep.value = 4; }
async function saveRankingStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
@ -242,7 +249,9 @@ function downloadBuiltInFile(url, fileName) {
link.click();
document.body.removeChild(link);
}
function exportChannelRows() { const headers = ["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-5)"]; const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore])].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = "产品发布渠道评估-渠道数据.csv"; link.click(); URL.revokeObjectURL(url); }
function downloadCsv(headers, rows, fileName) { const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...rows].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }
function exportChannelRows() { downloadCsv(["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-100)"], channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore]), "产品发布渠道评估-渠道数据.csv"); }
function exportScoreRows() { const headers = ["渠道", ...scoringMetrics.flatMap((metric) => [`${metric.dimension}得分`, `${metric.dimension}权重(%)`]), "加权得分"]; const rows = scoreRows.value.map((row) => [row.channel, ...row.dimensions.flatMap((dimension) => [dimension.score, dimension.weight]), row.weightedScore]); downloadCsv(headers, rows, "产品发布渠道评估-归一化评分.csv"); }
function restoreRows(raw, target, defaults, fields) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw; if (!Array.isArray(saved?.rows)) return; target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); return makeRow(target === weightRows ? { ...value, suggestion: weightSuggestions[value.dimension] || "" } : value); }); } catch (error) { target.value = defaults(); } }
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data; restoreRows(answer?.step1Answer, weightRows, defaultWeightRows, ["dimension", "description", "weight"]); restoreRows(answer?.step2Answer, channelRows, defaultChannelRows, ["channel", "dailySearchVolume", "supplyDemandRatio", "averageClickRate", "averageCpc", "averageConversionRate", "existingVisitorCount", "matchingScore"]); let savedScores = []; try { const saved = typeof answer?.step3Answer === "string" ? JSON.parse(answer.step3Answer || "{}") : answer?.step3Answer; savedScores = Array.isArray(saved?.rows) ? saved.rows : []; } catch (error) { savedScores = []; } syncScoreRows(savedScores); let savedRanking = []; try { const saved = typeof answer?.step4Answer === "string" ? JSON.parse(answer.step4Answer || "{}") : answer?.step4Answer; savedRanking = Array.isArray(saved?.rows) ? saved.rows : []; coreChannelAnalysis.value = String(saved?.coreChannelAnalysis || ""); } catch (error) { savedRanking = []; coreChannelAnalysis.value = ""; } syncRankingRows(savedRanking); activeStep.value = Math.min(Math.max(Number(answer?.currentStep) || 1, 1), 4); } catch (error) { syncScoreRows(); syncRankingRows(); } }
onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = result?.data || null; } catch (error) { taskConfig.value = null; } await loadProgress(); });

@ -284,4 +284,5 @@ onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY
</style>
<style scoped lang="scss">
.evaluation-form{padding-top:4px}.evaluation-intro{padding:18px;margin-top:14px;border:1px solid rgba(99,183,222,.36);border-radius:14px;background:rgba(8,42,64,.36)}.evaluation-intro p,.decision-section>p{color:#c8e5f1;line-height:1.75}.overall-roi{display:flex;flex-wrap:wrap;gap:12px;margin-top:14px}.overall-roi span{padding:7px 12px;border:1px solid rgba(99,183,222,.42);border-radius:999px;color:#d7f0fa;background:rgba(3,27,43,.58);font-weight:800}.overall-roi .achieved{border-color:rgba(81,224,178,.72);color:#86f3ca}.overall-roi .underperforming{border-color:rgba(255,152,152,.7);color:#ffb0b0}.evaluation-table-wrap{overflow-x:auto;margin-top:24px;border:1px solid rgba(99,183,222,.46);border-radius:14px}.evaluation-table{width:100%;min-width:1050px;border-collapse:collapse}.evaluation-table th,.evaluation-table td{padding:12px;border-right:1px solid rgba(99,183,222,.35);border-bottom:1px solid rgba(99,183,222,.35);vertical-align:middle}.evaluation-table th{white-space:nowrap;color:#effaff;background:#0c9dd6;font-size:14px}.evaluation-table td{background:rgba(2,21,34,.58)}.evaluation-table th:first-child{min-width:110px}.evaluation-table td:nth-child(2){min-width:100px}.evaluation-table td:nth-child(3){min-width:145px}.evaluation-table td:nth-child(4),.evaluation-table td:nth-child(5){min-width:250px}.evaluation-table :deep(.el-select){width:100%}.evaluation-table :deep(.el-input__wrapper),.evaluation-table :deep(.el-textarea__inner),.decision-section :deep(.el-textarea__inner){box-shadow:0 0 0 1px rgba(111,190,225,.3) inset;color:#fff;background:#102233}.synced-roi{color:#7ce4ff;text-align:center;font-size:16px;font-weight:900}.decision-section{margin-top:30px}.decision-section :deep(.el-textarea__inner){min-height:170px!important;padding:12px 14px;line-height:1.75}@media (max-width:720px){.overall-roi{align-items:stretch;flex-direction:column}}
.strategy-reference :deep(.el-collapse-item__content){color:#d8f2ff}.strategy-reference .reference-table td{color:#d8f2ff;font-size:14px;font-weight:500}.strategy-reference .reference-table td:nth-child(2),.strategy-reference .reference-table td:nth-child(3){color:#91e2ff;font-weight:700}
</style>

Loading…
Cancel
Save