feat: complete teacher task review workflow

bugfix/ai-assessment-evidence-rescore
陈沅 1 month ago
parent 9a837c6c03
commit b087a72a1e

@ -1,5 +1,6 @@
import request from "@/utils/request";
import { getDemoAnswerKey, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
import { getTrainingReviewPreviewContext } from "@/utils/trainingReviewPreview";
const newProductSurveyStepOneAnswers = {
"selling-point-1": "核心层",
@ -20,6 +21,10 @@ function demoResult(taskKey) {
}
export function getStudentTrainingAnswer(taskKey, params) {
const preview = getTrainingReviewPreviewContext();
if (preview) {
return Promise.resolve({ code: 200, data: preview.taskKey === taskKey ? preview.answer || null : null });
}
if (isStudentDemo()) return Promise.resolve({ code: 200, data: demoResult(taskKey) });
return request({
url: `/api/student-training-answers/${taskKey}`,

@ -1,5 +1,6 @@
import request from "@/utils/request";
import { getUserInfo } from "@/utils/auth";
import { getTrainingReviewPreviewContext } from "@/utils/trainingReviewPreview";
// 学生页面和全局“补充步骤”导航会在同一轮路由渲染中读取同一任务配置。
// 仅合并进行中的请求:教师保存后下一次读取仍会获得最新配置,不引入陈旧缓存。
@ -67,6 +68,16 @@ export function loadStudentTrainingTasks() {
export function getTrainingTaskByKey(taskKey) {
const normalizedTaskKey = String(taskKey || "").trim();
const preview = getTrainingReviewPreviewContext();
if (preview) {
if (preview.taskKey !== normalizedTaskKey) {
return Promise.resolve({ code: 200, data: null });
}
if (!preview.taskConfig) {
return Promise.reject(new Error("评阅案例配置未加载,不能展示通用案例"));
}
return Promise.resolve({ code: 200, data: preview.taskConfig });
}
if (pendingTaskRequests.has(normalizedTaskKey)) {
return pendingTaskRequests.get(normalizedTaskKey);
}

@ -499,6 +499,13 @@ export const dynamicRoutes = [
];
// 教师端路由
export const teacherRoutes = [
{
path: "/task-review-preview",
component: () => import("@/views/teacherEnd/taskReview/StudentTaskReadonlyPreview.vue"),
roles: ["teacher"],
hidden: true,
meta: { title: "学生任务预览", affix: false },
},
{
path: "/school",
component: Layout,

@ -6,6 +6,7 @@ import { tansParams, blobValidate } from "@/utils/ruoyi";
import cache from "@/plugins/cache";
import { saveAs } from "file-saver";
import useUserStore from "@/store/modules/user";
import { isTrainingReviewPreviewRequest } from "@/utils/trainingReviewPreview";
let downloadLoadingInstance;
// 是否显示重新登录
@ -24,6 +25,11 @@ const service = axios.create({
// request拦截器
service.interceptors.request.use(
(config) => {
if (isTrainingReviewPreviewRequest(config)) {
const error = new Error("任务评阅预览为只读模式,不能修改学生作答");
error.config = config;
return Promise.reject(error);
}
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false;
// 是否需要防止数据重复提交

@ -0,0 +1,53 @@
const PREVIEW_STORAGE_PREFIX = "teacher-task-review-preview:";
function currentLocation() {
return typeof window === "undefined" ? null : window.location;
}
function previewKeyFromLocation() {
const location = currentLocation();
if (!location) return "";
const directKey = new URLSearchParams(location.search).get("reviewPreviewKey");
if (directKey) return directKey;
const hashQueryIndex = location.hash.indexOf("?");
if (hashQueryIndex < 0) return "";
return new URLSearchParams(location.hash.slice(hashQueryIndex + 1)).get("reviewPreviewKey") || "";
}
export function getTrainingReviewPreviewKey() {
return previewKeyFromLocation();
}
export function writeTrainingReviewPreview(previewKey, value) {
if (!previewKey || typeof sessionStorage === "undefined") return;
sessionStorage.setItem(`${PREVIEW_STORAGE_PREFIX}${previewKey}`, JSON.stringify(value));
}
export function readTrainingReviewPreview(previewKey) {
if (!previewKey || typeof sessionStorage === "undefined") return null;
try {
return JSON.parse(sessionStorage.getItem(`${PREVIEW_STORAGE_PREFIX}${previewKey}`) || "null");
} catch (_) {
return null;
}
}
export function clearTrainingReviewPreview(previewKey) {
if (previewKey && typeof sessionStorage !== "undefined") {
sessionStorage.removeItem(`${PREVIEW_STORAGE_PREFIX}${previewKey}`);
}
}
export function getTrainingReviewPreviewContext() {
return readTrainingReviewPreview(getTrainingReviewPreviewKey());
}
export function isTrainingReviewPreviewRequest(config = {}) {
const method = String(config.method || "get").toLowerCase();
const url = String(config.url || "").split("?")[0];
return Boolean(getTrainingReviewPreviewKey())
&& ["post", "put", "patch", "delete"].includes(method)
&& (url.startsWith("/api/student-training-answers/")
|| url.startsWith("/api/student/project-study-time/")
|| url === "/common/upload");
}

@ -25,6 +25,7 @@
</div>
<el-table
ref="studentTableRef"
class="task-review__student-table"
:data="review.students"
border
size="small"
@ -34,13 +35,13 @@
@selection-change="onSelectionChange"
@row-click="selectStudent"
>
<el-table-column type="selection" width="42" :selectable="canSelectStudent" />
<el-table-column prop="name" label="姓名" min-width="84" show-overflow-tooltip />
<el-table-column prop="username" label="学号" min-width="92" show-overflow-tooltip />
<el-table-column label="AI评分" width="72" align="center">
<el-table-column type="selection" width="38" :selectable="canSelectStudent" />
<el-table-column prop="name" label="姓名" width="74" show-overflow-tooltip />
<el-table-column prop="username" label="学号" width="88" show-overflow-tooltip />
<el-table-column label="AI评分" width="66" align="center">
<template #default="{ row }">{{ formatScore(row.aiAssessmentScore) }}</template>
</el-table-column>
<el-table-column label="老师评分" min-width="112" align="center">
<el-table-column label="老师评分" width="94" align="center">
<template #default="{ row }">
<el-input-number
v-if="canManageClass && row.scorable"
@ -74,16 +75,15 @@
<span>老师评分 <strong>{{ formatScore(selectedStudent.teacherScore) }}</strong></span>
</div>
</header>
<el-steps :active="completedStepCount" finish-status="success" align-center class="task-review__steps">
<el-step v-for="(step, index) in taskSteps" :key="`${index}-${step}`" :title="step" />
</el-steps>
<el-empty v-if="!hasAnswer" description="该学生尚未保存任务内容" :image-size="104" />
<el-collapse v-else v-model="openSteps" class="task-review__answers">
<el-collapse-item v-for="(step, index) in answerSteps" :key="step.name" :name="step.name">
<template #title>第{{ index + 1 }}步:{{ step.name }}</template>
<pre>{{ formatAnswer(step.value) }}</pre>
</el-collapse-item>
</el-collapse>
<iframe
v-if="studentPreviewSrc"
:key="studentPreviewSrc"
class="task-review__student-preview"
:src="studentPreviewSrc"
scrolling="yes"
title="学生任务页面只读预览"
/>
<el-empty v-else :description="studentPreviewError || '当前任务暂不支持页面预览'" :image-size="104" />
</template>
<el-empty v-else description="请选择教学班和实训任务后查看学生任务步骤" :image-size="128" />
</section>
@ -102,7 +102,9 @@
<script setup>
import { ElMessage } from "element-plus";
import * as teacherApi from "@/api/teacher";
import { listClassTrainingTasks } from "@/api/trainingTask";
import useUserStore from "@/store/modules/user";
import { clearTrainingReviewPreview, writeTrainingReviewPreview } from "@/utils/trainingReviewPreview";
const props = defineProps({
teachingClassList: { type: Array, default: () => [] },
@ -123,6 +125,10 @@ const batchScore = ref(null);
const selectedStudents = ref([]);
const selectedStudentId = ref("");
const studentTableRef = ref();
const previewKey = ref("");
const studentPreviewSrc = ref("");
const studentPreviewError = ref("");
let previewRequestVersion = 0;
const canManageClass = computed(() => {
const currentClass = teachingClasses.value.find((item) => item.schoolClassId === filters.value.classId);
@ -130,16 +136,6 @@ const canManageClass = computed(() => {
});
const selectedStudent = computed(() => review.value.students.find((row) => row.studentUserId === selectedStudentId.value) || review.value.students[0] || null);
const canBatchScore = computed(() => canManageClass.value && selectedStudents.value.length > 0 && Boolean(filters.value.taskId));
const taskSteps = computed(() => parseSteps(review.value.selectedTask?.steps));
const answerSteps = computed(() => {
if (!selectedStudent.value) return [];
const values = [selectedStudent.value.step1Answer, selectedStudent.value.step2Answer, selectedStudent.value.step3Answer, selectedStudent.value.step4Answer];
const names = taskSteps.value.length ? taskSteps.value : values.map((_, index) => `任务步骤${index + 1}`);
return values.map((value, index) => ({ name: names[index] || `任务步骤${index + 1}`, value })).filter((step) => step.value);
});
const hasAnswer = computed(() => answerSteps.value.length > 0 || Boolean(selectedStudent.value?.dynamicStepAnswers));
const openSteps = ref([]);
const completedStepCount = computed(() => Math.min(answerSteps.value.length, taskSteps.value.length || 4));
watch(() => props.initialClassId, (classId) => {
if (!filters.value.classId && classId) {
@ -153,31 +149,45 @@ watch(teachingClasses, (classes) => {
loadReview();
}
}, { immediate: true });
watch(selectedStudent, (student) => {
if (student) openSteps.value = answerSteps.value.map((step) => step.name);
}, { flush: "post" });
watch([selectedStudent, () => review.value.selectedTask], refreshStudentPreview, { immediate: true, flush: "post" });
function parseSteps(rawSteps) {
if (!rawSteps) return ["任务步骤1", "任务步骤2", "任务步骤3", "任务步骤4"];
async function refreshStudentPreview() {
const requestVersion = ++previewRequestVersion;
if (previewKey.value) clearTrainingReviewPreview(previewKey.value);
const taskKey = review.value.selectedTask?.taskKey;
const teachingClassId = filters.value.classId;
const student = selectedStudent.value;
studentPreviewError.value = "";
if (!student || !taskKey || !teachingClassId) {
previewKey.value = "";
studentPreviewSrc.value = "";
return;
}
studentPreviewSrc.value = "";
try {
const parsed = JSON.parse(rawSteps);
if (Array.isArray(parsed)) return parsed.map((step, index) => typeof step === "string" ? step : step?.name || step?.title || `任务步骤${index + 1}`);
// 班级任务列表返回该班实际生效的配置:教师覆盖优先,未覆盖时使用管理员案例。
const response = await listClassTrainingTasks(filters.value.classId, { enabledOnly: false });
if (requestVersion !== previewRequestVersion) return;
const effectiveTask = (response?.data || []).find((task) => task.taskKey === taskKey);
if (!effectiveTask) throw new Error("未找到该教学班的有效实训案例");
const nextPreviewKey = `review-${Date.now()}-${Math.random().toString(36).slice(2)}`;
writeTrainingReviewPreview(nextPreviewKey, {
taskKey,
teachingClassId,
taskConfig: effectiveTask,
answer: { ...student },
});
previewKey.value = nextPreviewKey;
studentPreviewSrc.value = `${window.location.pathname}#/task-review-preview?taskKey=${encodeURIComponent(taskKey)}&reviewPreviewKey=${encodeURIComponent(nextPreviewKey)}`;
} catch (_) {
// 任务配置不是 JSON 时,保留一个可读的步骤标题。
if (requestVersion !== previewRequestVersion) return;
previewKey.value = "";
studentPreviewError.value = "该教学班的实训案例加载失败,未展示通用案例";
}
return ["任务步骤1", "任务步骤2", "任务步骤3", "任务步骤4"];
}
function formatScore(score) {
return score === null || score === undefined || score === "" ? "--" : score;
}
function formatAnswer(value) {
if (!value) return "未填写";
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch (_) {
return value;
}
}
function normalizeReview(data) {
const students = (data?.students || []).map((row) => ({ ...row, teacherScoreDraft: row.teacherScore ?? null }));
review.value = { projects: data?.projects || [], tasks: data?.tasks || [], selectedTask: data?.selectedTask || null, students };
@ -260,16 +270,20 @@ async function saveBatchScore() {
}
}
onMounted(loadTeachingClasses);
onBeforeUnmount(() => clearTrainingReviewPreview(previewKey.value));
</script>
<style lang="scss" scoped>
.task-review { display: grid; grid-template-columns: minmax(430px, 40%) minmax(0, 1fr); min-height: calc(100vh - 142px); border: 1px solid rgba(39, 172, 238, 0.28); border-radius: 14px; overflow: hidden; background: linear-gradient(135deg, rgba(6, 23, 36, 0.84), rgba(4, 12, 23, 0.78)); box-shadow: 0 18px 48px rgba(0, 5, 16, 0.34), inset 0 1px 0 rgba(153, 220, 255, 0.08); }
.task-review__sidebar { padding: 20px; border-right: 1px solid rgba(54, 159, 212, 0.25); overflow: auto; background: rgba(2, 18, 31, 0.48); }
.task-review__filters { display: grid; grid-template-columns: repeat(3, minmax(112px, 1fr)); gap: 12px; padding: 14px; border: 1px solid rgba(44, 169, 226, 0.28); border-radius: 10px; background: rgba(2, 18, 31, 0.64); }
.task-review { display: grid; grid-template-columns: minmax(360px, 400px) minmax(0, 1fr); min-height: calc(100vh - 142px); border: 1px solid rgba(39, 172, 238, 0.28); border-radius: 14px; overflow: hidden; background: linear-gradient(135deg, rgba(6, 23, 36, 0.84), rgba(4, 12, 23, 0.78)); box-shadow: 0 18px 48px rgba(0, 5, 16, 0.34), inset 0 1px 0 rgba(153, 220, 255, 0.08); }
.task-review__sidebar { min-width: 0; padding: 16px; border-right: 1px solid rgba(54, 159, 212, 0.25); overflow: auto; background: rgba(2, 18, 31, 0.48); }
.task-review__filters { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 12px; border: 1px solid rgba(44, 169, 226, 0.28); border-radius: 10px; background: rgba(2, 18, 31, 0.64); }
.task-review__filters :deep(.el-form-item) { margin-bottom: 0; }
.task-review__filters :deep(.el-select) { width: 100%; }
.task-review__list-head { display: flex; align-items: center; justify-content: space-between; margin: 18px 0 4px; color: #dff6ff; font-size: 16px; font-weight: 700; }
.task-review__score-input { width: 88px; }
.task-review__score-input { width: 70px; }
.task-review__student-table { width: 100%; }
.task-review__student-table :deep(.cell) { padding: 0 6px; }
.task-review__student-table :deep(th .cell) { white-space: nowrap; }
.task-review__readonly-tip { margin: 12px 0 0; color: #9abed8; font-size: 12px; }
.task-review__content { min-width: 0; padding: 28px 34px; background: linear-gradient(135deg, rgba(5, 25, 40, 0.58), rgba(3, 13, 24, 0.38)); }
.task-review__student-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid rgba(54, 159, 212, 0.26); }
@ -278,15 +292,7 @@ onMounted(loadTeachingClasses);
.task-review__scores { display: flex; gap: 10px; color: #b9d9eb; font-size: 13px; white-space: nowrap; }
.task-review__scores span { padding: 7px 10px; border: 1px solid rgba(50, 166, 223, 0.28); border-radius: 999px; background: rgba(2, 26, 43, 0.68); }
.task-review__scores strong { margin-left: 5px; color: #77d9ff; font-size: 17px; }
.task-review__steps { margin: 34px 0 26px; padding: 0 8px 8px; }
.task-review__steps :deep(.el-step__icon) { border-color: rgba(112, 204, 248, 0.68); background: #071b2a; }
.task-review__steps :deep(.el-step__head.is-success), .task-review__steps :deep(.el-step__title.is-success) { color: #75d7ff; border-color: #75d7ff; }
.task-review__steps :deep(.el-step__line) { background-color: rgba(93, 176, 218, 0.42); }
.task-review__steps :deep(.el-step__title) { min-height: 42px; padding: 0 7px; color: #bcd9e9; font-size: 14px; line-height: 1.45; white-space: normal; }
.task-review__answers { border-color: rgba(54, 159, 212, 0.24); }
.task-review__answers :deep(.el-collapse-item__header) { height: auto; min-height: 48px; padding: 10px 14px; color: #dff6ff; border-bottom-color: rgba(54, 159, 212, 0.22); background: rgba(4, 28, 45, 0.7); line-height: 1.5; }
.task-review__answers :deep(.el-collapse-item__wrap) { border-bottom-color: rgba(54, 159, 212, 0.22); background: rgba(2, 15, 26, 0.66); }
.task-review__answers :deep(pre) { margin: 0; padding: 16px; overflow: auto; border-radius: 0; background: transparent; color: #cce9f8; font-family: Consolas, Monaco, monospace; font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
.task-review__student-preview { display: block; width: 100%; height: calc(100vh - 260px); min-height: 580px; margin-top: 22px; overflow: auto; border: 1px solid rgba(54, 159, 212, 0.32); border-radius: 10px; background: #061724; }
.task-review__batch-copy { margin: 0 0 18px; color: #dff6ff; }
.task-review__batch-input { width: 100%; }
:deep(.task-review__selected-row > td) { background: rgba(0, 111, 177, 0.26) !important; }

@ -0,0 +1,73 @@
<template>
<main class="student-task-readonly-preview" @click.capture="guardPreviewInteraction" @keydown.capture="guardPreviewInteraction">
<component v-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
<el-empty v-else :description="emptyDescription" :image-size="112" />
</main>
</template>
<script setup>
import { getTrainingReviewPreviewContext } from "@/utils/trainingReviewPreview";
import { studentTaskPreviewComponentByKey, studentTaskPreviewPropsByKey } from "./studentTaskPreviewMap";
const route = useRoute();
const previewComponent = shallowRef(null);
const previewProps = ref({});
const emptyDescription = ref("正在加载学生任务页面");
async function loadPreview() {
previewComponent.value = null;
const taskKey = String(route.query.taskKey || "");
const preview = getTrainingReviewPreviewContext();
const loader = studentTaskPreviewComponentByKey[taskKey];
if (!preview || preview.taskKey !== taskKey) {
emptyDescription.value = "学生作答预览已失效,请返回任务评阅重新选择学生";
return;
}
if (!loader) {
emptyDescription.value = "该实训任务暂不支持页面预览";
return;
}
try {
previewComponent.value = (await loader()).default;
previewProps.value = studentTaskPreviewPropsByKey[taskKey]?.(taskKey) || {};
} catch (_) {
emptyDescription.value = "学生任务页面加载失败,请刷新后重试";
}
}
watch(() => [route.query.taskKey, route.query.reviewPreviewKey], loadPreview, { immediate: true });
function guardPreviewInteraction(event) {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
if (target.closest("input, textarea, select, [contenteditable='true'], .el-input, .el-textarea, .el-select, .el-checkbox, .el-radio, .el-switch")) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
const action = target.closest("button, a, [role='button'], [role='tab'], .el-step");
if (action && !isStepNavigation(action)) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
function isStepNavigation(element) {
if (element.matches("[role='tab'], .el-step")) return true;
const label = String(element.getAttribute("aria-label") || "");
const text = `${label} ${String(element.textContent || "")}`.replace(/\s+/g, " ").trim();
return /(上一步|下一步|第\s*\d+\s*步|切换.*步骤|步骤.*切换)/.test(text);
}
</script>
<style lang="scss" scoped>
:global(html), :global(body) { overflow-y: scroll; scrollbar-gutter: stable; background: #061724; scrollbar-color: #238db8 #061724; }
:global(html::-webkit-scrollbar) { width: 12px; }
:global(html::-webkit-scrollbar-track) { background: #061724; }
:global(html::-webkit-scrollbar-thumb) { border: 2px solid #061724; border-radius: 8px; background: #238db8; }
.student-task-readonly-preview { min-height: 100vh; overflow: visible; pointer-events: auto; }
.student-task-readonly-preview :deep(input),
.student-task-readonly-preview :deep(textarea),
.student-task-readonly-preview :deep(select),
.student-task-readonly-preview :deep([contenteditable='true']) { pointer-events: none; }
</style>

@ -0,0 +1,30 @@
export const studentTaskPreviewComponentByKey = {
"new-product-survey": () => import("@/views/foundation/new-product-survey.vue"),
"product-development-factors": () => import("@/views/foundation/product-development-factors.vue"),
"product-development-process": () => import("@/views/foundation/product-development-process.vue"),
"industry-demand-analysis": () => import("@/views/demand/index.vue"),
"competitive-environment-analysis": () => import("@/views/demand/environment.vue"),
"market-opportunity-selection": () => import("@/views/product/index.vue"),
"business-feasibility": () => import("@/views/demand/business-feasibility.vue"),
"target-user-profile": () => import("@/views/demand/people.vue"),
"consumer-behavior": () => import("@/views/demand/consumer-behavior.vue"),
"consumer-scenario": () => import("@/views/demand/consumer-scenario.vue"),
"feature-conversion": () => import("@/views/demand/feature-conversion.vue"),
"product-value-positioning": () => import("@/views/product/differentiation.vue"),
"product-structure": () => import("@/views/product/structure.vue"),
"after-sales-service": () => import("@/views/product/after-sales-service.vue"),
"requirements-doc": () => import("@/views/product/requirements-doc.vue"),
"hardware-cost": () => import("@/views/product/hardware-cost.vue"),
"supplier-evaluation": () => import("@/views/channel/supplier.vue"),
"purchase-demand-forecast": () => import("@/views/channel/index.vue"),
"product-pricing": () => import("@/views/product/price.vue"),
"category-optimization": () => import("@/views/assessment/optimization.vue"),
"release-channel-evaluation": () => import("@/views/test/index.vue"),
"promotion-effect-optimization": () => import("@/views/test/promotion.vue"),
"product-diagnosis-iteration": () => import("@/views/assessment/diagnosis.vue"),
"comprehensive-case-training": () => import("@/views/training/GenericTrainingPage.vue"),
};
export const studentTaskPreviewPropsByKey = {
"comprehensive-case-training": (taskKey) => ({ previewTaskKey: taskKey }),
};

@ -414,8 +414,11 @@ import { resolveProjectName, useProjectStudyTime } from "@/composables/useProjec
const route = useRoute();
const { proxy } = getCurrentInstance();
const props = defineProps({
previewTaskKey: { type: String, default: "" },
});
const pageKey = computed(() => route.meta?.pageKey || route.params?.pageKey);
const pageKey = computed(() => props.previewTaskKey || route.meta?.pageKey || route.params?.pageKey);
const isDemo = computed(() => isStudentDemo());
const isProductDevelopmentProcess = computed(() => pageKey.value === "product-development-process");
const isComprehensiveCaseTraining = computed(() => pageKey.value === "comprehensive-case-training");

@ -13,6 +13,6 @@ assert.match(sidebar, /\{ title: "任务评阅", path: "\/task-review\/index" \}
assert.doesNotMatch(scorePage, /label="任务评阅"/);
assert.match(reviewPage, /v-for="item in teachingClasses"/);
assert.match(reviewPage, /\.task-review \{[\s\S]*?background: linear-gradient\(135deg, rgba\(6, 23, 36, 0\.84\), rgba\(4, 12, 23, 0\.78\)\)/);
assert.match(reviewPage, /\.task-review__steps :deep\(\.el-step__title\) \{[\s\S]*?white-space: normal/);
assert.match(reviewPage, /\.task-review__student-preview \{[\s\S]*?min-height: 580px/);
console.log("task review navigation is a standalone teacher menu item");

@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const previewUtilsPath = resolve(root, "src/utils/trainingReviewPreview.js");
assert.equal(existsSync(previewUtilsPath), true, "评阅预览上下文工具应存在");
const previewUtils = readFileSync(previewUtilsPath, "utf8");
const answerApi = readFileSync(resolve(root, "src/api/studentTrainingAnswer.js"), "utf8");
const trainingTaskApi = readFileSync(resolve(root, "src/api/trainingTask.js"), "utf8");
const requestApi = readFileSync(resolve(root, "src/utils/request.js"), "utf8");
const previewMapPath = resolve(root, "src/views/teacherEnd/taskReview/studentTaskPreviewMap.js");
const previewShellPath = resolve(root, "src/views/teacherEnd/taskReview/StudentTaskReadonlyPreview.vue");
const router = readFileSync(resolve(root, "src/router/index.js"), "utf8");
const reviewPanel = readFileSync(resolve(root, "src/views/teacherEnd/score/TaskReviewPanel.vue"), "utf8");
assert.match(previewUtils, /export function writeTrainingReviewPreview/);
assert.match(previewUtils, /export function getTrainingReviewPreviewContext/);
assert.match(previewUtils, /student\/project-study-time/);
assert.match(answerApi, /getTrainingReviewPreviewContext\(\)/);
assert.match(trainingTaskApi, /getTrainingReviewPreviewContext/);
assert.match(trainingTaskApi, /preview\.taskConfig/);
assert.match(trainingTaskApi, /评阅案例配置未加载/);
assert.match(requestApi, /isTrainingReviewPreviewRequest/);
assert.equal(existsSync(previewMapPath), true, "全部学生任务的预览映射应存在");
assert.equal(existsSync(previewShellPath), true, "学生任务只读预览容器应存在");
const previewMap = readFileSync(previewMapPath, "utf8");
const previewShell = readFileSync(previewShellPath, "utf8");
const expectedTaskKeys = [
"new-product-survey", "product-development-factors", "product-development-process",
"industry-demand-analysis", "competitive-environment-analysis", "market-opportunity-selection",
"business-feasibility", "target-user-profile", "consumer-behavior", "consumer-scenario",
"feature-conversion", "product-value-positioning", "product-structure", "after-sales-service",
"requirements-doc", "hardware-cost", "supplier-evaluation", "purchase-demand-forecast",
"product-pricing", "category-optimization", "release-channel-evaluation",
"promotion-effect-optimization", "product-diagnosis-iteration", "comprehensive-case-training",
];
for (const taskKey of expectedTaskKeys) assert.match(previewMap, new RegExp(`"${taskKey}"`));
assert.match(router, /path:\s*"\/task-review-preview"[\s\S]*?hidden:\s*true/);
assert.match(previewShell, /@click\.capture="guardPreviewInteraction"/);
assert.match(previewShell, /function guardPreviewInteraction/);
assert.match(previewShell, /getAttribute\("aria-label"\)/);
assert.match(previewShell, /pointer-events:\s*auto/);
assert.match(previewShell, /overflow-y:\s*scroll/);
assert.match(previewShell, /scrollbar-gutter:\s*stable/);
assert.match(previewShell, /scrollbar-color:\s*#238db8\s+#061724/);
assert.match(reviewPanel, /writeTrainingReviewPreview/);
assert.match(reviewPanel, /listClassTrainingTasks\(filters\.value\.classId/);
assert.match(reviewPanel, /find\(\(task\) => task\.taskKey === taskKey\)/);
assert.match(reviewPanel, /taskConfig:\s*effectiveTask/);
assert.match(reviewPanel, /previewRequestVersion/);
assert.match(reviewPanel, /studentPreviewError/);
assert.match(reviewPanel, /<iframe[^>]+studentPreviewSrc/);
assert.match(reviewPanel, /scrolling="yes"/);
assert.match(reviewPanel, /grid-template-columns: minmax\(360px, 400px\) minmax\(0, 1fr\)/);
assert.match(reviewPanel, /class="task-review__student-table"/);
assert.match(reviewPanel, /type="selection" width="38"/);
assert.match(reviewPanel, /prop="name" label="姓名" width="74"/);
assert.match(reviewPanel, /prop="username" label="学号" width="88"/);
assert.match(reviewPanel, /label="AI评分" width="66"/);
assert.match(reviewPanel, /label="老师评分" width="94"/);
assert.match(reviewPanel, /background:\s*#061724/);
assert.doesNotMatch(reviewPanel, /task-review__student-preview[^}]*background:\s*#fff/);
assert.doesNotMatch(reviewPanel, /<el-collapse[\s\S]*?formatAnswer/);
console.log("task review preview context blocks student-answer writes");
Loading…
Cancel
Save