feat: complete teacher task review workflow
parent
9a837c6c03
commit
b087a72a1e
@ -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");
|
||||
}
|
||||
@ -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 }),
|
||||
};
|
||||
@ -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…
Reference in New Issue