前台 — 路由 (router/index.ts)

  - my-timetable 路由的角色从 ['Student'] 扩展为 ['Student', 'Teacher']
  - 新增 teacher-timetable/:teacherId 路由,用于查看指定教师的课表

  前台 — 课表视图 (TimetableView.vue)

  - 新增 isTeacherView 和 isTeacher 计算属性,区分教师个人课表和查看他人课表
  - 教师个人课表:标题显示"我的授课课表",描述改为"展示本人本学期所有授课安排"
  - 查看他人课表:标题显示"教师课表"
  - 两种模式均隐藏资源筛选面板,只保留学期选择
  - 加载逻辑适配教师模式 API

  前台 — 教师档案页 (PersonnelView.vue)

  - 教师行操作列新增 "课表" 按钮(带日历图标),仅在职教师可点击
  - 点击后跳转到该教师的授课课表页面

  前台 — 侧边导航 (AdminLayout.vue)

  - 教师角色可见"我的授课课表"菜单项,与学生的"我的课表"并列
This commit is contained in:
2026-07-25 13:21:06 +08:00 Unverified
parent a081273ac6
commit 7800951aee
5 changed files with 171 additions and 37 deletions
@@ -91,6 +91,45 @@ public sealed class TimetablesController(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
BuildTimetableAsync(classId, academicTermId, null, cancellationToken); BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
[HttpGet("teachers/{teacherId:guid}")]
[AllowAnonymous]
public async Task<ActionResult> GetTeacherTimetable(
Guid teacherId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
null,
false,
null,
null,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("teachers/{teacherId:guid}/export.xlsx")]
[AllowAnonymous]
public async Task<ActionResult> ExportTeacherTimetable(
Guid teacherId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
null,
false,
null,
null,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
[HttpGet("classes/{classId:guid}/export.xlsx")] [HttpGet("classes/{classId:guid}/export.xlsx")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult> ExportClassTimetable( public async Task<ActionResult> ExportClassTimetable(
@@ -112,13 +151,14 @@ public sealed class TimetablesController(
} }
[HttpGet("mine")] [HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTimetable( public async Task<ActionResult> GetMyTimetable(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized(); return Unauthorized();
var student = await db.Students.AsNoTracking() var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.Select(x => new .Select(x => new
@@ -129,14 +169,8 @@ public sealed class TimetablesController(
x.Name x.Name
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (student is null) if (student is not null)
return Conflict(new ProblemDetails
{ {
Title = "学生档案未关联",
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
return await BuildTimetableAsync( return await BuildTimetableAsync(
student.AdministrativeClassId, student.AdministrativeClassId,
academicTermId, academicTermId,
@@ -145,14 +179,41 @@ public sealed class TimetablesController(
new TimetableStudentDto(student.StudentNumber, student.Name)); new TimetableStudentDto(student.StudentNumber, student.Name));
} }
var teacher = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id })
.FirstOrDefaultAsync(cancellationToken);
if (teacher is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacher.Id,
academicTermId,
null,
false,
null,
null,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
return Conflict(new ProblemDetails
{
Title = "档案未关联",
Detail = "当前登录账号没有关联教师或学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
}
[HttpGet("mine/export.xlsx")] [HttpGet("mine/export.xlsx")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> ExportMyTimetable( public async Task<ActionResult> ExportMyTimetable(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized(); return Unauthorized();
var student = await db.Students.AsNoTracking() var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.Select(x => new .Select(x => new
@@ -163,7 +224,8 @@ public sealed class TimetablesController(
x.Name x.Name
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (student is null) return NotFound(); if (student is not null)
{
var result = await timetableDataService.BuildAsync( var result = await timetableDataService.BuildAsync(
TimetableResourceType.Class, TimetableResourceType.Class,
student.AdministrativeClassId, student.AdministrativeClassId,
@@ -177,6 +239,28 @@ public sealed class TimetablesController(
return ExcelFile(result); return ExcelFile(result);
} }
var teacher = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id })
.FirstOrDefaultAsync(cancellationToken);
if (teacher is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacher.Id,
academicTermId,
null,
false,
null,
null,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
return NotFound();
}
private async Task<ActionResult> BuildTimetableAsync( private async Task<ActionResult> BuildTimetableAsync(
Guid classId, Guid classId,
Guid? academicTermId, Guid? academicTermId,
+1 -1
View File
@@ -104,7 +104,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin']), hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
{ path: '/schedules', label: '排课与课表' }, { path: '/schedules', label: '排课与课表' },
), ),
...whenVisible(isStudent.value, { path: '/my-timetable', label: '我的课表' }), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
...whenVisible(!isStudent.value, { ...whenVisible(!isStudent.value, {
path: '/class-timetable', path: '/class-timetable',
+7 -1
View File
@@ -141,7 +141,13 @@ const router = createRouter({
path: 'my-timetable', path: 'my-timetable',
name: 'my-timetable', name: 'my-timetable',
component: () => import('../views/TimetableView.vue'), component: () => import('../views/TimetableView.vue'),
meta: { roles: ['Student'], mine: true }, meta: { roles: ['Student', 'Teacher'], mine: true },
},
{
path: 'teacher-timetable/:teacherId',
name: 'teacher-timetable',
component: () => import('../views/TimetableView.vue'),
meta: { teacherView: true },
}, },
{ {
path: 'free-classrooms', path: 'free-classrooms',
+18 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Document, Download, Key, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue' import { Calendar, Document, Download, Key, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -16,6 +16,7 @@ interface PageResult {
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const active = computed<Registry>(() => const active = computed<Registry>(() =>
route.meta.registry === 'students' ? 'students' : 'teachers', route.meta.registry === 'students' ? 'students' : 'teachers',
) )
@@ -341,6 +342,10 @@ async function activateTeacherAccount() {
} }
} }
function viewTimetable(teacher: any) {
router.push({ name: 'teacher-timetable', params: { teacherId: teacher.id } })
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadReferences(), load()]) await Promise.all([loadReferences(), load()])
}) })
@@ -468,7 +473,7 @@ watch(active, async () => {
<el-table-column <el-table-column
v-if="canManage" v-if="canManage"
label="操作" label="操作"
:width="active === 'teachers' ? 225 : 145" :width="active === 'teachers' ? 280 : 145"
fixed="right" fixed="right"
> >
<template #default="{ row }"> <template #default="{ row }">
@@ -482,6 +487,16 @@ watch(active, async () => {
> >
激活账号 激活账号
</el-button> </el-button>
<el-button
v-if="active === 'teachers'"
link
type="warning"
:icon="Calendar"
:disabled="row.status !== 'Active'"
@click="viewTimetable(row)"
>
课表
</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button> <el-button link type="danger" @click="remove(row)">删除</el-button>
</template> </template>
+34 -5
View File
@@ -10,9 +10,13 @@ const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const isMine = computed(() => route.meta.mine === true) const isMine = computed(() => route.meta.mine === true)
const isPublic = computed(() => route.meta.public === true) const isPublic = computed(() => route.meta.public === true)
const isTeacherView = computed(() => route.meta.teacherView === true)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)))
const teacherIdParam = computed(() => route.params.teacherId as string | undefined)
const isManager = computed(() => const isManager = computed(() =>
!isMine.value && !isMine.value &&
!isPublic.value && !isPublic.value &&
!isTeacherView.value &&
(auth.user?.roles.some((role) => (auth.user?.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false), ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false),
) )
@@ -378,7 +382,11 @@ function onResourceTypeChanged() {
} }
async function loadTimetable() { async function loadTimetable() {
if (!termId.value || (!isMine.value && !selectedResourceId.value)) { if (!termId.value) {
timetable.value = null
return
}
if (!isMine.value && !isTeacherView.value && !selectedResourceId.value) {
timetable.value = null timetable.value = null
return return
} }
@@ -388,6 +396,10 @@ async function loadTimetable() {
timetable.value = (await http.get('/timetables/mine', { timetable.value = (await http.get('/timetables/mine', {
params: { academicTermId: termId.value }, params: { academicTermId: termId.value },
})).data })).data
} else if (isTeacherView.value && teacherIdParam.value) {
timetable.value = (await http.get(`/timetables/teachers/${teacherIdParam.value}`, {
params: { academicTermId: termId.value },
})).data
} else if (isManager.value) { } else if (isManager.value) {
timetable.value = (await http.get('/timetables/management/query', { timetable.value = (await http.get('/timetables/management/query', {
params: { params: {
@@ -418,6 +430,11 @@ async function exportExcel() {
`/timetables/mine/export.xlsx?academicTermId=${termId.value}`, `/timetables/mine/export.xlsx?academicTermId=${termId.value}`,
'我的课表.xlsx', '我的课表.xlsx',
) )
} else if (isTeacherView.value && teacherIdParam.value) {
await downloadApiFile(
`/timetables/teachers/${teacherIdParam.value}/export.xlsx?academicTermId=${termId.value}`,
`${timetable.value?.subject?.name ?? '教师'}课表.xlsx`,
)
} else if (isManager.value) { } else if (isManager.value) {
const query = new URLSearchParams({ const query = new URLSearchParams({
resourceType: resourceType.value, resourceType: resourceType.value,
@@ -478,9 +495,19 @@ async function exportPdf() {
} }
watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable) watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
watch(teacherIdParam, (newId) => { if (newId) loadTimetable() })
onMounted(async () => { onMounted(async () => {
try { try {
if (isTeacherView.value) {
const { data } = await http.get('/timetables/options')
terms.value = data.terms
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
?? data.terms[0]?.id
?? ''
await loadTimetable()
return
}
await loadPublicOptions() await loadPublicOptions()
if (isManager.value) await loadManagementOptions() if (isManager.value) await loadManagementOptions()
await loadTimetable() await loadTimetable()
@@ -499,9 +526,11 @@ onMounted(async () => {
<section class="timetable-heading"> <section class="timetable-heading">
<div> <div>
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span> <span class="section-kicker">{{ isMine && isTeacher ? 'MY TEACHING SCHEDULE' : isMine ? 'MY TIMETABLE' : isTeacherView ? 'TEACHER TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2> <h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
<p v-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p> <p v-if="isMine && isTeacher">展示本人本学期所有授课安排仅采用教务处已发布课表</p>
<p v-else-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else-if="isTeacherView">查看指定教师本学期的授课安排</p>
<p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p> <p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p> <p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p>
</div> </div>
@@ -531,7 +560,7 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section v-if="!isMine" class="resource-filter-panel"> <section v-if="!isMine && !isTeacherView" class="resource-filter-panel">
<div v-if="isManager" class="resource-type-tabs"> <div v-if="isManager" class="resource-type-tabs">
<el-radio-group v-model="resourceType" @change="onResourceTypeChanged"> <el-radio-group v-model="resourceType" @change="onResourceTypeChanged">
<el-radio-button value="Class">班级课表</el-radio-button> <el-radio-button value="Class">班级课表</el-radio-button>