评教系统

This commit is contained in:
2026-07-25 18:41:53 +08:00 Unverified
parent 15a34a306c
commit 376bc0d0a1
12 changed files with 6667 additions and 487 deletions
+431
View File
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? [])
const isStudent = computed(() => roles.value.includes('Student'))
const isTeacher = computed(() => roles.value.includes('Teacher') && !roles.value.some((r: string) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
const isAdmin = computed(() => roles.value.some((r: string) => ['SuperAdmin', 'AcademicAdmin'].includes(r)))
const isStaff = computed(() => roles.value.some((r: string) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(r)))
// ── Admin: Setups ──
const setups = ref<any[]>([])
const terms = ref<any[]>([])
const setupLoading = ref(false)
const setupDialog = ref(false)
const editingSetup = ref<any>(null)
const setupForm = reactive({
academicTermId: undefined as string | undefined,
name: '',
startsAt: '',
endsAt: '',
dimensions: [] as { name: string; maxScore: number }[],
})
const dimSum = computed(() => setupForm.dimensions.reduce((s, d) => s + d.maxScore, 0))
function addDim() { setupForm.dimensions.push({ name: '', maxScore: 10 }) }
function removeDim(i: number) { setupForm.dimensions.splice(i, 1) }
function resetSetupForm() {
setupForm.academicTermId = undefined
setupForm.name = ''
setupForm.startsAt = ''
setupForm.endsAt = ''
setupForm.dimensions = [
{ name: '教学态度', maxScore: 20 },
{ name: '教学内容', maxScore: 30 },
{ name: '教学方法', maxScore: 30 },
{ name: '教学效果', maxScore: 20 },
]
}
async function loadSetups() {
setupLoading.value = true
try { setups.value = (await http.get('/evaluations/setups')).data }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { setupLoading.value = false }
}
function openCreate() {
editingSetup.value = null; resetSetupForm(); setupDialog.value = true
}
function openEdit(setup: any) {
editingSetup.value = setup
setupForm.academicTermId = setup.academicTermId
setupForm.name = setup.name
setupForm.startsAt = setup.startsAt?.split('T')[0] ?? ''
setupForm.endsAt = setup.endsAt?.split('T')[0] ?? ''
setupForm.dimensions = (setup.dimensions ?? []).map((d: any) => ({ name: d.name, maxScore: d.maxScore }))
setupDialog.value = true
}
async function saveSetup() {
if (!setupForm.name.trim()) { ElMessage.warning('请输入方案名称'); return }
if (!setupForm.academicTermId) { ElMessage.warning('请选择学期'); return }
if (dimSum.value !== 100) { ElMessage.warning('维度满分必须合计 100'); return }
try {
const payload = {
academicTermId: setupForm.academicTermId,
name: setupForm.name,
startsAt: setupForm.startsAt || null,
endsAt: setupForm.endsAt || null,
dimensions: setupForm.dimensions,
}
if (editingSetup.value) {
await http.put(`/evaluations/setups/${editingSetup.value.id}`, payload)
ElMessage.success('方案已更新')
} else {
await http.post('/evaluations/setups', payload)
ElMessage.success('方案已创建')
}
setupDialog.value = false
await loadSetups()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function openSetup(id: string) {
try { await http.post(`/evaluations/setups/${id}/open`); ElMessage.success('已开启评教'); await loadSetups() }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function closeSetup(id: string) {
try { await http.post(`/evaluations/setups/${id}/close`); ElMessage.success('已关闭评教'); await loadSetups() }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function deleteSetup(id: string) {
try {
await ElMessageBox.confirm('删除后不可恢复,确定删除?', '删除评教方案', { type: 'warning', confirmButtonText: '删除' })
await http.delete(`/evaluations/setups/${id}`)
ElMessage.success('已删除')
await loadSetups()
} catch (e: any) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) }
}
// ── Admin: Results ──
const resultSetupId = ref<string>()
const results = ref<any>(null)
const resultLoading = ref(false)
async function loadResults() {
if (!resultSetupId.value) return
resultLoading.value = true
try { results.value = (await http.get(`/evaluations/setups/${resultSetupId.value}/results`)).data }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { resultLoading.value = false }
}
// ── Student: Evaluation ──
const studentTasks = ref<any[]>([])
const studentLoading = ref(false)
const evalDialog = ref(false)
const evalFormData = ref<any>(null)
const evalScores = ref<Record<string, number>>({})
const evalSubmitting = ref(false)
async function loadStudentTasks() {
studentLoading.value = true
try { studentTasks.value = ((await http.get('/evaluations/my-tasks')).data).tasks ?? [] }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { studentLoading.value = false }
}
async function openEval(task: any) {
try {
const { data } = await http.get(`/evaluations/tasks/${task.teachingTaskId}/form`)
evalFormData.value = data
evalScores.value = {}
for (const d of data.dimensions) evalScores.value[d.id] = Math.floor(d.maxScore * 0.8)
evalDialog.value = true
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
const evalTotal = computed(() => {
if (!evalFormData.value) return 0
return evalFormData.value.dimensions.reduce((s: number, d: any) => s + (evalScores.value[d.id] ?? 0), 0)
})
async function submitEval() {
evalSubmitting.value = true
try {
await http.post(`/evaluations/tasks/${evalFormData.value.task.id}/submit`, {
setupId: evalFormData.value.setupId,
scores: evalFormData.value.dimensions.map((d: any) => ({
dimensionId: d.id,
score: evalScores.value[d.id] ?? 0,
})),
})
ElMessage.success('评教提交成功,感谢您的反馈!')
evalDialog.value = false
await loadStudentTasks()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { evalSubmitting.value = false }
}
// ── Teacher: Results ──
const teacherResults = ref<any[]>([])
const teacherLoading = ref(false)
async function loadTeacherResults() {
teacherLoading.value = true
try { teacherResults.value = ((await http.get('/evaluations/my-results')).data).tasks ?? [] }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { teacherLoading.value = false }
}
// ── Init ──
onMounted(async () => {
if (isAdmin.value || isStaff.value) {
const [tRes] = await Promise.all([http.get('/base-data/terms')])
terms.value = tRes.data
await loadSetups()
}
if (isStudent.value) await loadStudentTasks()
if (isTeacher.value) await loadTeacherResults()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">EVALUATION</span>
<h2>{{ isStudent ? '学生评教' : isTeacher ? '评教结果' : '教学评价' }}</h2>
<p v-if="isStudent">请对本学期修读的每门课程进行客观评价每门课满分 100 </p>
<p v-else-if="isTeacher">查看您所授课程的学生评教平均分</p>
<p v-else>管理评教方案查看评教结果统计</p>
</div>
</section>
<!-- Admin / Staff: Manage Setups -->
<template v-if="isAdmin || isStaff">
<el-tabs v-model="resultSetupId" @tab-change="loadResults">
<el-tab-pane label="评教方案" name="">
<section class="data-card" v-loading="setupLoading">
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button>
</div>
<el-table :data="setups" size="small" empty-text="暂无评教方案">
<el-table-column prop="name" label="方案名称" min-width="200" />
<el-table-column prop="termName" label="学期" width="140" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'Draft' ? 'info' : row.status === 'Open' ? 'success' : 'warning'" size="small">
{{ row.status === 'Draft' ? '草稿' : row.status === 'Open' ? '进行中' : '已关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="维度" min-width="200">
<template #default="{ row }">
<span v-for="d in row.dimensions" :key="d.id">
<el-tag size="small" style="margin:1px">{{ d.name }}({{ d.maxScore }})</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="recordCount" label="评教数" width="80" />
<el-table-column label="操作" width="200" fixed="right" v-if="isAdmin">
<template #default="{ row }">
<el-button v-if="row.status === 'Draft'" size="small" :icon="Edit" @click="openEdit(row)" text>编辑</el-button>
<el-button v-if="row.status === 'Draft'" size="small" type="success" :icon="Unlock" @click="openSetup(row.id)" text>开启</el-button>
<el-button v-if="row.status === 'Open'" size="small" type="danger" :icon="Lock" @click="closeSetup(row.id)" text>关闭</el-button>
<el-button v-if="row.status !== 'Open' && row.recordCount === 0" size="small" type="danger" :icon="Delete" @click="deleteSetup(row.id)" text>删除</el-button>
</template>
</el-table-column>
</el-table>
</section>
</el-tab-pane>
<el-tab-pane v-for="s in setups.filter((x: any) => x.status !== 'Draft')" :key="s.id" :label="s.name" :name="s.id">
<section class="data-card" v-loading="resultLoading">
<div class="metrics-strip" v-if="results">
<span class="metric-chip"><b>{{ results.totalRecords }}</b> 条评教</span>
<span class="metric-chip"><b>{{ results.distinctTasks }}</b> 个教学班</span>
<span class="metric-chip"><b>{{ results.distinctStudents }}</b> 名学生</span>
</div>
<el-table :data="results?.byTask ?? []" size="small" empty-text="暂无评教数据">
<el-table-column prop="taskNumber" label="教学班号" width="120" />
<el-table-column prop="courseName" label="课程" min-width="150" />
<el-table-column prop="teacherNames" label="教师" width="100" />
<el-table-column prop="collegeName" label="开课学院" width="120" />
<el-table-column label="总分均分" width="100" sortable="custom">
<template #default="{ row }">
<b :style="{ color: row.averageTotal >= 80 ? '#67C23A' : row.averageTotal >= 60 ? '#E6A23C' : '#F56C6C' }">
{{ row.averageTotal }}
</b>
</template>
</el-table-column>
<el-table-column v-for="dim in results?.setup?.dimensions ?? []" :key="dim.id" :label="dim.name + '(' + dim.maxScore + ')'" width="110">
<template #default="{ row }">
{{ (row.dimensions?.find((x: any) => x.dimensionName === dim.name))?.averageScore ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="studentCount" label="评教人数" width="80" />
</el-table>
</section>
</el-tab-pane>
</el-tabs>
<!-- Setup Dialog -->
<el-dialog v-model="setupDialog" :title="editingSetup ? '编辑评教方案' : '新建评教方案'" width="640px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="学期">
<el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="方案名称">
<el-input v-model="setupForm.name" placeholder="如:2026年秋季学期学生评教" maxlength="120" />
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="setupForm.startsAt" type="datetime" placeholder="可选" style="width:100%" />
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker v-model="setupForm.endsAt" type="datetime" placeholder="可选" style="width:100%" />
</el-form-item>
</div>
<el-divider>评分维度 <el-tag :type="dimSum === 100 ? 'success' : 'danger'" size="small">{{ dimSum === 100 ? '已平衡 ✓' : '合计 ' + dimSum + '(需为 100' }}</el-tag></el-divider>
<div v-for="(dim, idx) in setupForm.dimensions" :key="idx" style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
<el-input v-model="dim.name" placeholder="维度名称" style="flex:2" maxlength="60" />
<el-input-number v-model="dim.maxScore" :min="1" :max="100" placeholder="满分" style="flex:1" />
<el-button :icon="Delete" circle size="small" type="danger" @click="removeDim(idx)" :disabled="setupForm.dimensions.length <= 1" />
</div>
<el-button :icon="Plus" size="small" @click="addDim" style="margin-top:4px">添加维度</el-button>
</el-form>
<template #footer>
<el-button @click="setupDialog = false">取消</el-button>
<el-button type="primary" @click="saveSetup" :disabled="dimSum !== 100">保存方案</el-button>
</template>
</el-dialog>
</template>
<!-- Student: Evaluate -->
<template v-if="isStudent">
<section class="data-card" v-loading="studentLoading">
<el-empty v-if="studentTasks.length === 0" description="暂无可评教的课程" />
<div v-else class="eval-task-list">
<article v-for="task in studentTasks" :key="task.teachingTaskId" class="eval-task-card">
<div class="eval-task-info">
<b>{{ task.courseName }}</b>
<span>{{ task.courseCode }} · {{ task.teacherNames?.join('、') }}</span>
<small>{{ task.taskNumber }}</small>
</div>
<div class="eval-task-action">
<el-tag v-if="task.isEvaluated" type="success" size="small">已评教</el-tag>
<el-button v-else type="primary" :icon="Edit" @click="openEval(task)" size="small">去评教</el-button>
</div>
</article>
</div>
</section>
<!-- Eval Dialog -->
<el-dialog v-model="evalDialog" title="学生评教" width="580px" :close-on-click-modal="false">
<template v-if="evalFormData">
<div class="eval-header">
<b>{{ evalFormData.task.courseName }}</b>
<span>{{ evalFormData.task.teacherNames?.join('、') }}</span>
<small>{{ evalFormData.task.taskNumber }}</small>
</div>
<el-divider />
<div v-for="dim in evalFormData.dimensions" :key="dim.id" style="margin-bottom:20px">
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
<b>{{ dim.name }}</b>
<el-tag size="small">{{ evalScores[dim.id] ?? 0 }} / {{ dim.maxScore }} </el-tag>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:12px;color:#909399">0</span>
<el-slider v-model="evalScores[dim.id]" :max="dim.maxScore" :step="1" show-stops style="flex:1" />
<span style="font-size:12px;color:#909399">{{ dim.maxScore }}</span>
</div>
</div>
<el-divider />
<div style="text-align:center">
<span style="font-size:24px;font-weight:700;color:#409EFF">{{ evalTotal }}</span>
<span style="color:#909399"> / 100 </span>
</div>
</template>
<template #footer>
<el-button @click="evalDialog = false">取消</el-button>
<el-button type="primary" :loading="evalSubmitting" @click="submitEval">提交评教</el-button>
</template>
</el-dialog>
</template>
<!-- Teacher: Results -->
<template v-if="isTeacher">
<section class="data-card" v-loading="teacherLoading">
<el-empty v-if="teacherResults.length === 0" description="暂无评教结果" />
<div v-else>
<article v-for="item in teacherResults" :key="item.task.id" class="teacher-result-card">
<div class="result-header">
<div>
<b>{{ item.task.courseName }}</b>
<span>{{ item.task.courseCode }} · {{ item.task.taskNumber }}</span>
<small>{{ item.setupName }}</small>
</div>
<div class="result-total">
<span :style="{ color: item.averageTotal >= 80 ? '#67C23A' : item.averageTotal >= 60 ? '#E6A23C' : '#F56C6C' }">
{{ item.averageTotal }}
</span>
<small>/ 100 · {{ item.studentCount }} 人参评</small>
</div>
</div>
<div class="result-dims">
<div v-for="dim in item.dimensions" :key="dim.dimensionId" class="result-dim-item">
<span>{{ dim.dimensionName }}</span>
<el-progress :percentage="Math.round(dim.averageScore / dim.maxScore * 100)" :color="dim.averageScore / dim.maxScore >= 0.8 ? '#67C23A' : '#E6A23C'" style="flex:1;margin:0 12px" />
<b>{{ dim.averageScore }}<small>/{{ dim.maxScore }}</small></b>
</div>
</div>
</article>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.page-stack { max-width: 1200px; margin: 0 auto }
.metrics-strip { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap }
.metric-chip {
background: var(--el-color-primary-light-9, #ecf5ff); color: var(--el-color-primary, #409EFF);
padding: 4px 14px; border-radius: 16px; font-size: 13px;
}
.eval-task-list { display: flex; flex-direction: column; gap: 10px }
.eval-task-card {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border: 1px solid #e4e7ed; border-radius: 8px;
}
.eval-task-info { display: flex; flex-direction: column; gap: 2px }
.eval-task-info b { font-size: 15px }
.eval-task-info span { font-size: 13px; color: #606266 }
.eval-task-info small { font-size: 12px; color: #909399 }
.eval-header { text-align: center }
.eval-header b { display: block; font-size: 16px }
.eval-header span { color: #606266; font-size: 14px }
.eval-header small { display: block; color: #909399; font-size: 12px }
.teacher-result-card {
border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px; margin-bottom: 12px;
}
.result-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px }
.result-header b { display: block; font-size: 16px }
.result-header span { font-size: 13px; color: #606266 }
.result-header small { display: block; font-size: 12px; color: #909399 }
.result-total { text-align: right }
.result-total span { font-size: 28px; font-weight: 700 }
.result-total small { display: block; font-size: 12px; color: #909399 }
.result-dims { display: flex; flex-direction: column; gap: 8px }
.result-dim-item { display: flex; align-items: center }
.result-dim-item > span { width: 80px; font-size: 13px; flex-shrink: 0 }
.result-dim-item b { font-size: 14px; min-width: 60px; text-align: right }
.result-dim-item b small { font-size: 11px; color: #909399; font-weight: 400 }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px }
</style>