fix: lock training task step counts

bugfix/ai-assessment-evidence-rescore
chenyuan 1 month ago
parent 48d1e00c0f
commit e0c6e7a6cd

@ -72,7 +72,7 @@
<div class="drawer-heading"> <div class="drawer-heading">
<span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span> <span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span>
<h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3> <h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3>
<p>管理员维护系统默认实训配置,教学班未单独配置时学生端使用这里的实训背景、目标、要求和案例文档。</p> <p>管理员维护系统默认实训配置;步骤数量与学生端保持一致,只能修改步骤名称。</p>
</div> </div>
<div class="drawer-header-actions"> <div class="drawer-header-actions">
<button type="button" class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">x</button> <button type="button" class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">x</button>
@ -166,16 +166,14 @@
</el-upload> </el-upload>
</div> </div>
<el-form-item label="步骤名称:"> <el-form-item :label="`步骤名称(固定 ${taskForm.steps.length} 步):`">
<div class="step-row"> <div class="step-row">
<template v-for="(step, index) in taskForm.steps" :key="step.id"> <template v-for="(step, index) in taskForm.steps" :key="step.id">
<div class="step-item"> <div class="step-item">
<el-input v-model="step.name" placeholder="步骤名称" /> <el-input v-model="step.name" placeholder="步骤名称" />
<el-button v-if="!isReadonly" text type="danger" @click="removeStep(index)">删除</el-button>
</div> </div>
<span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span> <span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span>
</template> </template>
<el-button v-if="!isReadonly" plain class="add-step-button" @click="addStep">新增步骤</el-button>
</div> </div>
</el-form-item> </el-form-item>
</section> </section>
@ -198,6 +196,7 @@ import {
updateTrainingTask, updateTrainingTask,
uploadTrainingTaskMaterial, uploadTrainingTaskMaterial,
} from "@/api/trainingTask"; } from "@/api/trainingTask";
import { getStudentTrainingStepCount } from "@/views/training/taskKeyMap";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
@ -315,7 +314,7 @@ function normalizeTask(task) {
requirements: task.requirements || "", requirements: task.requirements || "",
materialName: material.name, materialName: material.name,
materialUrl: material.url, materialUrl: material.url,
steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())), steps: normalizeTaskSteps(task.taskKey, parseArray(task.steps, [])),
sort: task.sort || 0, sort: task.sort || 0,
enabled: task.enabled !== false, enabled: task.enabled !== false,
}; };
@ -354,17 +353,16 @@ function parseArray(value, fallback) {
} }
} }
function normalizeSteps(steps) { function normalizeTaskSteps(taskKey, steps) {
const source = Array.isArray(steps) ? steps : []; const source = Array.isArray(steps) ? steps : [];
const result = source const expectedCount = getStudentTrainingStepCount(taskKey);
.map((item, index) => { return Array.from({ length: expectedCount }, (_, index) => {
const item = source[index];
const raw = item && typeof item === "object" ? item : {}; const raw = item && typeof item === "object" ? item : {};
const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim(); const savedName = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim();
const kind = raw.kind === "custom" ? "custom" : index < 4 ? "builtin" : "custom"; const name = savedName || `步骤 ${String(index + 1).padStart(2, "0")}`;
return createStep(name, kind, raw.id); return createStep(name, "builtin", `builtin-${index + 1}`);
}) });
.filter((item) => item.name);
return result.length ? result : [createStep()];
} }
function createBuiltinSteps() { function createBuiltinSteps() {
@ -390,7 +388,7 @@ function cloneTask(task) {
return { return {
...task, ...task,
objectives: [...(task.objectives?.length ? task.objectives : [""])], objectives: [...(task.objectives?.length ? task.objectives : [""])],
steps: normalizeSteps(task.steps?.length ? task.steps : createBuiltinSteps()), steps: normalizeTaskSteps(task.taskKey, task.steps),
}; };
} }
@ -403,15 +401,6 @@ function removeObjective(index) {
taskForm.objectives.splice(index, 1); taskForm.objectives.splice(index, 1);
} }
function addStep() {
taskForm.steps.push(createStep());
}
function removeStep(index) {
if (taskForm.steps.length <= 1) return;
taskForm.steps.splice(index, 1);
}
async function handleImportChange(file) { async function handleImportChange(file) {
if (!file?.raw) return; if (!file?.raw) return;
await importTrainingTasks(file.raw); await importTrainingTasks(file.raw);
@ -466,6 +455,7 @@ async function submitTask() {
} }
function buildPayload() { function buildPayload() {
const fixedSteps = normalizeTaskSteps(taskForm.taskKey, taskForm.steps);
return { return {
projectName: taskForm.project, projectName: taskForm.project,
taskKey: taskForm.taskKey, taskKey: taskForm.taskKey,
@ -473,7 +463,7 @@ function buildPayload() {
background: taskForm.background, background: taskForm.background,
objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)), objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)),
requirements: taskForm.requirements, requirements: taskForm.requirements,
steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)), steps: JSON.stringify(fixedSteps),
materialName: taskForm.materialName, materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl, materialUrl: taskForm.materialUrl,
sort: taskForm.sort, sort: taskForm.sort,
@ -1195,10 +1185,6 @@ function buildPayload() {
flex: 1 1 260px; flex: 1 1 260px;
} }
.add-step-button {
border-style: dashed;
}
.step-arrow { .step-arrow {
color: #7ddaff; color: #7ddaff;
font-weight: 800; font-weight: 800;

@ -104,7 +104,7 @@
<div class="drawer-heading"> <div class="drawer-heading">
<span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span> <span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span>
<h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3> <h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3>
<p>老师可维护任务显示名称、实训说明、目标、要求和步骤,并可新增或删除步骤。</p> <p>老师可维护任务显示名称、实训说明、目标、要求和步骤名称;步骤数量与学生端保持一致。</p>
</div> </div>
<div class="drawer-header-actions"> <div class="drawer-header-actions">
<button type="button" class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">x</button> <button type="button" class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">x</button>
@ -188,16 +188,14 @@
</el-upload> </el-upload>
</div> </div>
<el-form-item label="步骤名称:"> <el-form-item :label="`步骤名称(固定 ${taskForm.steps.length} 步):`">
<div class="step-row"> <div class="step-row">
<template v-for="(step, index) in taskForm.steps" :key="step.id"> <template v-for="(step, index) in taskForm.steps" :key="step.id">
<div class="step-item"> <div class="step-item">
<el-input v-model="step.name" placeholder="步骤名称" /> <el-input v-model="step.name" placeholder="步骤名称" />
<el-button v-if="!isReadonly" text type="danger" @click="removeStep(index)">删除</el-button>
</div> </div>
<span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span> <span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span>
</template> </template>
<el-button v-if="!isReadonly" plain class="add-step-button" @click="addStep">新增步骤</el-button>
</div> </div>
</el-form-item> </el-form-item>
</section> </section>
@ -235,6 +233,7 @@ import {
import { createStudentDemoSession } from "@/api/studentDemo"; import { createStudentDemoSession } from "@/api/studentDemo";
import * as indexApi from "@/api/teacher"; import * as indexApi from "@/api/teacher";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import { getStudentTrainingStepCount } from "@/views/training/taskKeyMap";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const userStore = useUserStore(); const userStore = useUserStore();
@ -410,7 +409,7 @@ function normalizeTask(task) {
requirements: task.requirements || "", requirements: task.requirements || "",
materialName: material.name, materialName: material.name,
materialUrl: material.url, materialUrl: material.url,
steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())), steps: normalizeTaskSteps(task.taskKey, parseArray(task.steps, [])),
sort: task.sort || 0, sort: task.sort || 0,
enabled: task.enabled !== false, enabled: task.enabled !== false,
}; };
@ -467,17 +466,16 @@ function parseArray(value, fallback) {
} }
} }
function normalizeSteps(steps) { function normalizeTaskSteps(taskKey, steps) {
const source = Array.isArray(steps) ? steps : []; const source = Array.isArray(steps) ? steps : [];
const result = source const expectedCount = getStudentTrainingStepCount(taskKey);
.map((item, index) => { return Array.from({ length: expectedCount }, (_, index) => {
const item = source[index];
const raw = item && typeof item === "object" ? item : {}; const raw = item && typeof item === "object" ? item : {};
const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim(); const savedName = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim();
const kind = raw.kind === "custom" ? "custom" : index < 4 ? "builtin" : "custom"; const name = savedName || `步骤 ${String(index + 1).padStart(2, "0")}`;
return createStep(name, kind, raw.id); return createStep(name, "builtin", `builtin-${index + 1}`);
}) });
.filter((item) => item.name);
return result.length ? result : [createStep()];
} }
function createBuiltinSteps() { function createBuiltinSteps() {
@ -502,7 +500,7 @@ function cloneTask(task) {
return { return {
...task, ...task,
objectives: [...(task.objectives?.length ? task.objectives : [""])], objectives: [...(task.objectives?.length ? task.objectives : [""])],
steps: normalizeSteps(task.steps?.length ? task.steps : createBuiltinSteps()), steps: normalizeTaskSteps(task.taskKey, task.steps),
}; };
} }
@ -515,15 +513,6 @@ function removeObjective(index) {
taskForm.objectives.splice(index, 1); taskForm.objectives.splice(index, 1);
} }
function addStep() {
taskForm.steps.push(createStep());
}
function removeStep(index) {
if (taskForm.steps.length <= 1) return;
taskForm.steps.splice(index, 1);
}
async function handleMaterialChange(file) { async function handleMaterialChange(file) {
const rawFile = file?.raw; const rawFile = file?.raw;
if (!rawFile) return; if (!rawFile) return;
@ -632,6 +621,7 @@ async function confirmRestoreDefault(action) {
} }
function buildPayload() { function buildPayload() {
const fixedSteps = normalizeTaskSteps(taskForm.taskKey, taskForm.steps);
return { return {
projectName: taskForm.project, projectName: taskForm.project,
taskKey: taskForm.taskKey, taskKey: taskForm.taskKey,
@ -639,7 +629,7 @@ function buildPayload() {
background: taskForm.background, background: taskForm.background,
objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)), objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)),
requirements: taskForm.requirements, requirements: taskForm.requirements,
steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)), steps: JSON.stringify(fixedSteps),
materialName: taskForm.materialName, materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl, materialUrl: taskForm.materialUrl,
sort: taskForm.sort, sort: taskForm.sort,
@ -1358,10 +1348,6 @@ function buildPayload() {
flex: 1 1 260px; flex: 1 1 260px;
} }
.add-step-button {
border-style: dashed;
}
.step-arrow { .step-arrow {
color: #7ddaff; color: #7ddaff;
font-weight: 800; font-weight: 800;

@ -33,6 +33,40 @@ export const trainingTaskKeyByPath = {
"/assessment/optimization": "category-optimization", "/assessment/optimization": "category-optimization",
}; };
// Each training page implements a fixed number of student-facing steps. Keep the
// teacher editor aligned with those page capabilities instead of the legacy
// four-step backend default.
export const studentTrainingStepCountByTaskKey = Object.freeze({
"new-product-survey": 4,
"product-development-factors": 4,
"product-development-process": 4,
"industry-demand-analysis": 4,
"competitive-environment-analysis": 4,
"market-opportunity-selection": 6,
"business-feasibility": 3,
"target-user-profile": 5,
"consumer-behavior": 4,
"consumer-scenario": 4,
"feature-conversion": 4,
"product-value-positioning": 4,
"product-structure": 4,
"after-sales-service": 3,
"requirements-doc": 3,
"hardware-cost": 4,
"supplier-evaluation": 3,
"purchase-demand-forecast": 4,
"product-pricing": 5,
"category-optimization": 5,
"release-channel-evaluation": 4,
"promotion-effect-optimization": 4,
"product-diagnosis-iteration": 3,
"comprehensive-case-training": 10,
});
export function getStudentTrainingStepCount(taskKey, fallback = 4) {
return studentTrainingStepCountByTaskKey[String(taskKey || "").trim()] || fallback;
}
export function resolveTrainingTaskKey(route, explicitTaskKey = "") { export function resolveTrainingTaskKey(route, explicitTaskKey = "") {
return explicitTaskKey || route?.meta?.pageKey || trainingTaskKeyByPath[route?.path] || ""; return explicitTaskKey || route?.meta?.pageKey || trainingTaskKeyByPath[route?.path] || "";
} }

@ -0,0 +1,60 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const stepCounts = read("src/views/training/taskKeyMap.js");
const teacherEditor = read("src/views/teacherEnd/trainingTask/index.vue");
const adminEditor = read("src/views/schoolAdmin/trainingTask/index.vue");
const expectedCounts = {
"new-product-survey": 4,
"product-development-factors": 4,
"product-development-process": 4,
"industry-demand-analysis": 4,
"competitive-environment-analysis": 4,
"market-opportunity-selection": 6,
"business-feasibility": 3,
"target-user-profile": 5,
"consumer-behavior": 4,
"consumer-scenario": 4,
"feature-conversion": 4,
"product-value-positioning": 4,
"product-structure": 4,
"after-sales-service": 3,
"requirements-doc": 3,
"hardware-cost": 4,
"supplier-evaluation": 3,
"purchase-demand-forecast": 4,
"product-pricing": 5,
"category-optimization": 5,
"release-channel-evaluation": 4,
"promotion-effect-optimization": 4,
"product-diagnosis-iteration": 3,
"comprehensive-case-training": 10,
};
for (const [taskKey, count] of Object.entries(expectedCounts)) {
assert(
new RegExp(`"${taskKey}"\\s*:\\s*${count}(?:,|\\n)`).test(stepCounts),
`${taskKey} should be fixed to ${count} student steps`
);
}
for (const [name, editor] of [
["teacher", teacherEditor],
["admin", adminEditor],
]) {
assert(/import \{ getStudentTrainingStepCount \} from "@\/views\/training\/taskKeyMap";/.test(editor));
assert(/const expectedCount = getStudentTrainingStepCount\(taskKey\);/.test(editor));
assert(/Array\.from\(\{ length: expectedCount \}/.test(editor));
assert(/steps: JSON\.stringify\(fixedSteps\)/.test(editor));
assert(/步骤名称(固定 \$\{taskForm\.steps\.length\} 步)/.test(editor));
assert(!/@click="addStep"/.test(editor), `${name} editor must not add steps`);
assert(!/@click="removeStep/.test(editor), `${name} editor must not remove steps`);
assert(!/新增步骤/.test(editor), `${name} editor must not show add-step control`);
}
console.log("teacher and admin training task fixed-step contracts passed");
Loading…
Cancel
Save