feat: add teacher task review menu

bugfix/ai-assessment-evidence-rescore
陈沅 1 month ago
parent 1e51f7f977
commit 9256e8f4f4

@ -714,6 +714,20 @@ export function saveTaskTeacherScore(params) {
params, params,
}); });
} }
export function getTaskReview(params) {
return request({
url: "/api/weight/taskReview",
method: "GET",
params,
});
}
export function batchSaveTaskTeacherScore(data) {
return request({
url: "/api/weight/batchSaveTaskTeacherScore",
method: "POST",
data,
});
}
// 更新成绩 // 更新成绩
export function doRankOne(params) { export function doRankOne(params) {
return request({ return request({

@ -347,6 +347,7 @@ const teacherManageNav = [
{ title: "学生管理", path: "/student/index" }, { title: "学生管理", path: "/student/index" },
{ title: "实训任务管理", path: "/training-task/index" }, { title: "实训任务管理", path: "/training-task/index" },
{ title: "任务分配", path: "/task/index" }, { title: "任务分配", path: "/task/index" },
{ title: "任务评阅", path: "/task-review/index" },
{ title: "成绩中心", path: "/score/index" }, { title: "成绩中心", path: "/score/index" },
], ],
}, },
@ -418,6 +419,7 @@ const studentIconByPath = {
"/student/index": "Avatar", "/student/index": "Avatar",
"/training-task/index": "DocumentChecked", "/training-task/index": "DocumentChecked",
"/task/index": "Tickets", "/task/index": "Tickets",
"/task-review/index": "DocumentChecked",
"/score/index": "DataBoard", "/score/index": "DataBoard",
}; };
const studentIconBySectionStart = [ const studentIconBySectionStart = [

@ -632,8 +632,23 @@ export const teacherRoutes = [
], ],
}, },
// 成绩中心 // 成绩中心
{ {
path: "/score", path: "/task-review",
component: Layout,
redirect: "/task-review/index",
roles: ["teacher"],
meta: { title: "任务评阅", icon: "任务评阅", affix: true },
children: [
{
path: "index",
component: () => import("@/views/teacherEnd/score/TaskReviewPanel.vue"),
name: "taskReview",
meta: { title: "任务评阅", icon: "任务评阅", affix: true },
},
],
},
{
path: "/score",
component: Layout, component: Layout,
redirect: "/score/index", redirect: "/score/index",
roles: ["teacher"], roles: ["teacher"],

@ -0,0 +1,295 @@
<template>
<div class="task-review" v-loading="loading">
<aside class="task-review__sidebar">
<el-form label-position="top" class="task-review__filters">
<el-form-item label="实训项目">
<el-select v-model="filters.projectName" placeholder="请选择实训项目" clearable @change="onProjectChange">
<el-option v-for="project in review.projects" :key="project" :label="project" :value="project" />
</el-select>
</el-form-item>
<el-form-item label="实训任务">
<el-select v-model="filters.taskId" placeholder="请选择实训任务" :disabled="!filters.classId" @change="loadReview">
<el-option v-for="task in review.tasks" :key="task.taskId" :label="task.taskName" :value="task.taskId" />
</el-select>
</el-form-item>
<el-form-item label="教学班级">
<el-select v-model="filters.classId" placeholder="请选择教学班" @change="onClassChange">
<el-option v-for="item in teachingClasses" :key="item.schoolClassId" :label="item.className" :value="item.schoolClassId" />
</el-select>
</el-form-item>
</el-form>
<div class="task-review__list-head">
<span>学生列表({{ review.students.length }})</span>
<el-button type="primary" size="small" :disabled="!canBatchScore" @click="batchDialogVisible = true">批量打分</el-button>
</div>
<el-table
ref="studentTableRef"
:data="review.students"
border
size="small"
row-key="studentUserId"
highlight-current-row
:row-class-name="studentRowClass"
@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">
<template #default="{ row }">{{ formatScore(row.aiAssessmentScore) }}</template>
</el-table-column>
<el-table-column label="老师评分" min-width="112" align="center">
<template #default="{ row }">
<el-input-number
v-if="canManageClass && row.scorable"
v-model="row.teacherScoreDraft"
class="task-review__score-input"
:min="0"
:max="100"
:precision="0"
:controls="false"
placeholder="评分"
@change="saveSingleScore(row)"
@click.stop
/>
<el-tag v-else-if="!row.scorable" size="small" type="info">未完成</el-tag>
<span v-else>{{ formatScore(row.teacherScore) }}</span>
</template>
</el-table-column>
</el-table>
<p v-if="!canManageClass && filters.classId" class="task-review__readonly-tip">仅班级创建教师可评分</p>
</aside>
<section class="task-review__content">
<template v-if="selectedStudent && review.selectedTask">
<header class="task-review__student-head">
<div>
<h2>{{ selectedStudent.name || selectedStudent.username }}</h2>
<p>{{ review.selectedTask.projectName }} · {{ review.selectedTask.taskName }}</p>
</div>
<div class="task-review__scores">
<span>AI评分 <strong>{{ formatScore(selectedStudent.aiAssessmentScore) }}</strong></span>
<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>
</template>
<el-empty v-else description="请选择教学班和实训任务后查看学生任务步骤" :image-size="128" />
</section>
<el-dialog v-model="batchDialogVisible" title="批量打分" width="420px" append-to-body @closed="batchScore = null">
<p class="task-review__batch-copy">请给该实训任务下已选中的学生批量打分</p>
<el-input-number v-model="batchScore" :min="0" :max="100" :precision="0" :controls="false" class="task-review__batch-input" placeholder="请输入0-100分" />
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingBatch" @click="saveBatchScore">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ElMessage } from "element-plus";
import * as teacherApi from "@/api/teacher";
import useUserStore from "@/store/modules/user";
const props = defineProps({
teachingClassList: { type: Array, default: () => [] },
initialClassId: { type: String, default: "" },
});
const userStore = useUserStore();
const fallbackTeachingClassList = ref([]);
const teachingClasses = computed(() => props.teachingClassList.length
? props.teachingClassList
: fallbackTeachingClassList.value);
const filters = ref({ classId: "", projectName: "", taskId: "" });
const review = ref({ projects: [], tasks: [], selectedTask: null, students: [] });
const loading = ref(false);
const savingBatch = ref(false);
const batchDialogVisible = ref(false);
const batchScore = ref(null);
const selectedStudents = ref([]);
const selectedStudentId = ref("");
const studentTableRef = ref();
const canManageClass = computed(() => {
const currentClass = teachingClasses.value.find((item) => item.schoolClassId === filters.value.classId);
return Boolean(currentClass) && String(currentClass.createdBy || "") === String(userStore.userInfo.userId || "");
});
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) {
filters.value.classId = classId;
loadReview();
}
}, { immediate: true });
watch(teachingClasses, (classes) => {
if (!filters.value.classId && classes.length) {
filters.value.classId = props.initialClassId || classes[0].schoolClassId;
loadReview();
}
}, { immediate: true });
watch(selectedStudent, (student) => {
if (student) openSteps.value = answerSteps.value.map((step) => step.name);
}, { flush: "post" });
function parseSteps(rawSteps) {
if (!rawSteps) return ["任务步骤1", "任务步骤2", "任务步骤3", "任务步骤4"];
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}`);
} catch (_) {
// 任务配置不是 JSON 时,保留一个可读的步骤标题。
}
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 };
const selectedTask = review.value.selectedTask;
filters.value.taskId = selectedTask?.taskId || "";
if (selectedTask && !filters.value.projectName) filters.value.projectName = selectedTask.projectName || "";
if (!students.some((row) => row.studentUserId === selectedStudentId.value)) selectedStudentId.value = students[0]?.studentUserId || "";
selectedStudents.value = [];
studentTableRef.value?.clearSelection();
}
async function loadReview() {
if (!filters.value.classId) return;
loading.value = true;
try {
const res = await teacherApi.getTaskReview({ ...filters.value });
normalizeReview(res.data);
} finally {
loading.value = false;
}
}
async function loadTeachingClasses() {
if (props.teachingClassList.length) return;
const res = await teacherApi.getAccessibleTeachingClasses();
fallbackTeachingClassList.value = (res.data || []).filter((item) => item.classType === "TEACHING");
}
function onClassChange() {
filters.value.projectName = "";
filters.value.taskId = "";
loadReview();
}
function onProjectChange() {
filters.value.taskId = "";
loadReview();
}
function onSelectionChange(rows) {
selectedStudents.value = rows;
}
function canSelectStudent(row) {
return canManageClass.value && Boolean(row.scorable);
}
function selectStudent(row) {
selectedStudentId.value = row.studentUserId;
}
function studentRowClass({ row }) {
return row.studentUserId === selectedStudentId.value ? "task-review__selected-row" : "";
}
async function saveSingleScore(row) {
const score = row.teacherScoreDraft;
if (!Number.isInteger(score) || score < 0 || score > 100) {
ElMessage.error("教师评分必须为0至100之间的整数");
row.teacherScoreDraft = row.teacherScore ?? null;
return;
}
try {
await teacherApi.saveTaskTeacherScore({ classId: filters.value.classId, studentUserId: row.studentUserId, taskId: filters.value.taskId, teacherScore: score });
ElMessage.success("任务评分保存成功");
await loadReview();
} catch (_) {
row.teacherScoreDraft = row.teacherScore ?? null;
}
}
async function saveBatchScore() {
if (!Number.isInteger(batchScore.value) || batchScore.value < 0 || batchScore.value > 100) {
ElMessage.error("请输入0至100之间的整数分数");
return;
}
savingBatch.value = true;
try {
await teacherApi.batchSaveTaskTeacherScore({
classId: filters.value.classId,
taskId: filters.value.taskId,
studentUserIds: selectedStudents.value.map((row) => row.studentUserId),
teacherScore: batchScore.value,
});
ElMessage.success("批量任务评分保存成功");
batchDialogVisible.value = false;
await loadReview();
} finally {
savingBatch.value = false;
}
}
onMounted(loadTeachingClasses);
</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__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__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); }
.task-review__student-head h2 { margin: 0; color: #eef9ff; font-size: 24px; line-height: 1.25; }
.task-review__student-head p { margin: 8px 0 0; color: #9abed8; }
.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__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; }
@media (max-width: 1180px) { .task-review { grid-template-columns: 1fr; } .task-review__sidebar { border-right: 0; border-bottom: 1px solid rgba(54, 159, 212, 0.25); } }
@media (max-width: 720px) { .task-review__filters { grid-template-columns: 1fr; } .task-review__content { padding: 22px 18px; } .task-review__student-head { flex-direction: column; } .task-review__scores { flex-wrap: wrap; white-space: normal; } }
</style>

@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const router = readFileSync(resolve(root, "src/router/index.js"), "utf8");
const sidebar = readFileSync(resolve(root, "src/layout/components/Sidebar/index.vue"), "utf8");
const scorePage = readFileSync(resolve(root, "src/views/teacherEnd/score/index.vue"), "utf8");
const reviewPage = readFileSync(resolve(root, "src/views/teacherEnd/score/TaskReviewPanel.vue"), "utf8");
assert.match(router, /path:\s*"\/task-review"[\s\S]*?title:\s*"任务评阅"/);
assert.match(sidebar, /\{ title: "任务评阅", path: "\/task-review\/index" \},\s*\{ title: "成绩中心", path: "\/score\/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/);
console.log("task review navigation is a standalone teacher menu item");
Loading…
Cancel
Save