Files
Academic-Affairs-System/web/src/views/EvaluationView.vue
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

440 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import { academicTermLabel, academicTermOptionClass, academicTermRowClass, defaultAcademicTermId } from '../utils/academicTerms'
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 = defaultAcademicTermId(terms.value)
setupForm.name = ''
setupForm.startsAt = ''
setupForm.endsAt = ''
setupForm.dimensions = [
{ name: '教学态度', maxScore: 20 },
{ name: '教学内容', maxScore: 30 },
{ name: '教学方法', maxScore: 30 },
{ name: '教学效果', maxScore: 20 },
]
}
function setupRowClass({ row }: { row: any }) {
return academicTermRowClass({
isCurrent: row.termIsCurrent,
isArchived: row.termIsArchived,
})
}
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" :row-class-name="setupRowClass" 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="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</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>