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

302 lines
15 KiB
Vue

<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>