|
|
|
|
|
<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="老师评分" 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>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<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("");
|
|
|
|
|
|
let previewRequestVersion = 0;
|
|
|
|
|
|
|
|
|
|
|
|
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(() => 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, () => 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 = "";
|
|
|
|
|
|
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" : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
onBeforeUnmount(() => clearTrainingReviewPreview(previewKey.value));
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.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: 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 { 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; }
|
|
|
|
|
|
@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>
|