You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/teacherEnd/score/TaskReviewPanel.vue

319 lines
16 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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"
class="task-review__student-table"
: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="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="94" 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>
<div v-if="studentPreviewSrc" class="task-review__student-preview-scroll">
<iframe
:key="studentPreviewSrc"
class="task-review__student-preview"
:src="studentPreviewSrc"
:style="{ height: `${previewFrameHeight}px` }"
scrolling="yes"
title="学生任务页面只读预览"
/>
</div>
<el-empty v-else :description="studentPreviewError || '当前任务暂不支持页面预览'" :image-size="104" />
</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 { listClassTrainingTasks } from "@/api/trainingTask";
import useUserStore from "@/store/modules/user";
import { clearTrainingReviewPreview, writeTrainingReviewPreview } from "@/utils/trainingReviewPreview";
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 previewKey = ref("");
const studentPreviewSrc = ref("");
const studentPreviewError = ref("");
const previewFrameHeight = ref(580);
let previewRequestVersion = 0;
const PREVIEW_HEIGHT_MESSAGE = "teacher-task-review-preview-height";
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));
watch(teachingClasses, (classes) => {
if (!filters.value.classId && classes.length) {
const ownTeachingClass = classes.find((schoolClass) => String(schoolClass.createdBy || "") === String(userStore.userInfo.userId || ""));
filters.value.classId = ownTeachingClass?.schoolClassId || "";
if (filters.value.classId) loadReview();
}
}, { immediate: true });
watch([selectedStudent, () => review.value.selectedTask], refreshStudentPreview, { immediate: true, flush: "post" });
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 = "";
previewFrameHeight.value = 580;
if (!student || !taskKey || !teachingClassId) {
previewKey.value = "";
studentPreviewSrc.value = "";
return;
}
studentPreviewSrc.value = "";
try {
// 班级任务列表返回该班实际生效的配置:教师覆盖优先,未覆盖时使用管理员案例。
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 (_) {
if (requestVersion !== previewRequestVersion) return;
previewKey.value = "";
studentPreviewError.value = "该教学班的实训案例加载失败,未展示通用案例";
}
}
function formatScore(score) {
return score === null || score === undefined || score === "" ? "--" : score;
}
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" : "";
}
function onPreviewMessage(event) {
if (event.origin !== window.location.origin) return;
const data = event.data || {};
if (data.type !== PREVIEW_HEIGHT_MESSAGE || data.previewKey !== previewKey.value) return;
const height = Number(data.height);
if (Number.isFinite(height) && height > 0) previewFrameHeight.value = Math.max(580, Math.ceil(height));
}
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(() => {
window.addEventListener("message", onPreviewMessage);
loadTeachingClasses();
});
onBeforeUnmount(() => {
window.removeEventListener("message", onPreviewMessage);
clearTrainingReviewPreview(previewKey.value);
});
</script>
<style lang="scss" scoped>
.task-review { display: grid; grid-template-columns: minmax(400px, 460px) 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(2, minmax(0, 1fr)); gap: 8px 10px; 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: 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); }
.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__student-preview-scroll { height: calc(100vh - 260px); min-height: 580px; margin-top: 22px; overflow: auto; scrollbar-gutter: stable; scrollbar-color: #238db8 #061724; border: 1px solid rgba(54, 159, 212, 0.32); border-radius: 10px; }
.task-review__student-preview-scroll::-webkit-scrollbar { width: 12px; height: 12px; }
.task-review__student-preview-scroll::-webkit-scrollbar-track { background: #061724; }
.task-review__student-preview-scroll::-webkit-scrollbar-thumb { border: 2px solid #061724; border-radius: 8px; background: #238db8; }
.task-review__student-preview { display: block; width: 100%; min-width: 1280px; min-height: 580px; border: 0; }
.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>