调课、停课、补课申请

This commit is contained in:
2026-07-25 15:36:14 +08:00 Unverified
parent 9685435da9
commit 13b61f191a
11 changed files with 1162 additions and 1 deletions
+1
View File
@@ -12,6 +12,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+7
View File
@@ -130,6 +130,13 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/course-adjustments',
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
],
},
{
+8
View File
@@ -189,6 +189,14 @@ const router = createRouter({
component: () => import('../views/ExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
},
{
path: 'course-adjustments',
name: 'course-adjustments',
component: () => import('../views/CourseAdjustmentsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'],
},
},
{
path: 'student-status-changes',
name: 'student-status-changes',
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const adjustments = ref<any[]>([])
const pendingReviews = ref<any[]>([])
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const reviewLoading = ref(false)
const dialog = ref(false)
const rejectDialog = ref(false)
const currentAdj = ref<any>(null)
const rejectComment = ref('')
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine')
const form = reactive({
teachingTaskId: undefined as string | undefined,
type: 'Reschedule' as string,
reason: '',
submit: true,
targetDate: '' as string,
dayOfWeek: undefined as number | undefined,
startPeriod: undefined as number | undefined,
periodCount: 2,
classroomId: undefined as string | undefined,
substituteTeacherId: undefined as string | undefined,
cancelWeek: undefined as number | undefined,
cancelDate: '' as string,
})
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
const statusColors: Record<string, string> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
function periodOptions() {
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
}
async function load() {
loading.value = true
try {
if (isTeacher.value || (!isManager.value)) {
adjustments.value = (await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value },
})).data
}
if (isManager.value) {
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value },
})).data
}
await loadNotifications()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadNotifications() {
try {
const { data } = await http.get('/course-adjustments/notifications')
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
type: 'Reschedule',
reason: '',
submit: true,
targetDate: '',
dayOfWeek: undefined,
startPeriod: undefined,
periodCount: 2,
classroomId: undefined,
substituteTeacherId: undefined,
cancelWeek: undefined,
cancelDate: '',
})
dialog.value = true
}
async function submit() {
if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return }
if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return }
try {
await http.post('/course-adjustments', {
...form,
dayOfWeek: form.dayOfWeek || null,
startPeriod: form.startPeriod || null,
classroomId: form.classroomId || null,
substituteTeacherId: form.substituteTeacherId || null,
cancelWeek: form.cancelWeek || null,
targetDate: form.targetDate || null,
cancelDate: form.cancelDate || null,
})
dialog.value = false
ElMessage.success('申请已提交')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doSubmit(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/submit`)
ElMessage.success('已提交审核')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doApprove(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/approve`)
ElMessage.success('已通过')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function openReject(adj: any) {
currentAdj.value = adj
rejectComment.value = ''
rejectDialog.value = true
}
async function doReject() {
if (!currentAdj.value) return
try {
await http.post(`/course-adjustments/${currentAdj.value.id}/reject`, {
comment: rejectComment.value,
})
rejectDialog.value = false
ElMessage.success('已退回')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/course-adjustments/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
async function markAllRead() {
try {
await http.post('/course-adjustments/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
}
onMounted(async () => {
try {
const [termRes, taskRes] = await Promise.all([
http.get('/base-data/terms'),
isTeacher.value || !isManager.value
? http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } })
: Promise.resolve({ data: { items: [] } }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
termId.value = terms.value.find(t => t.isCurrent)?.id
} catch (_) {}
await load()
})
function showTypeFields(type: string) {
return type === 'Reschedule' || type === 'Makeup'
}
function showDate(type: string) { return type === 'Makeup' }
function showSub(type: string) { return type === 'Substitute' }
function showCancel(type: string) { return type === 'Cancel' }
</script>
<template>
<div class="page-stack adj-page">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE ADJUSTMENT</span>
<h2>调停课管理</h2>
<p>教师提交调课停课补课代课申请学院审核后生效并通知相关人员</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell" @click="tab = 'notifications'">消息</el-button>
</el-badge>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
{ label: '消息通知', value: 'notifications' },
]" />
</section>
<!-- My applications -->
<section v-if="tab === 'mine'" v-loading="loading" class="adj-list">
<article v-for="adj in adjustments" :key="adj.id" class="adj-card" :class="adj.status.toLowerCase()">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<div class="adj-tags">
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
<el-tag :type="statusColors[adj.status]" size="small">{{ statusLabels[adj.status] }}</el-tag>
</div>
</header>
<div class="adj-body">
<p v-if="adj.reason"><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<el-alert v-if="adj.reviewComment" :title="`审核意见:${adj.reviewComment}`" type="warning" :closable="false" show-icon />
</div>
<footer v-if="adj.status === 'Draft'">
<el-button size="small" type="primary" @click="doSubmit(adj)">提交审核</el-button>
</footer>
</article>
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
</section>
<!-- Pending reviews -->
<section v-if="tab === 'reviews'" v-loading="reviewLoading" class="adj-list">
<article v-for="adj in pendingReviews" :key="adj.id" class="adj-card review">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
</header>
<div class="adj-body">
<p><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<p class="adj-meta"><b>提交时间</b>{{ new Date(adj.submittedAt).toLocaleString('zh-CN') }}</p>
</div>
<footer>
<el-button size="small" type="danger" plain @click="openReject(adj)">退回</el-button>
<el-button size="small" type="success" :icon="Check" @click="doApprove(adj)">通过</el-button>
</footer>
</article>
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
</section>
<!-- Notifications -->
<section v-if="tab === 'notifications'" class="adj-list">
<div v-if="unreadCount" class="notif-actions">
<el-button size="small" text @click="markAllRead">全部标为已读</el-button>
</div>
<article v-for="n in notifications" :key="n.id" class="notif-card" :class="{ unread: !n.isRead }" @click="markRead(n)">
<div class="notif-dot" v-if="!n.isRead" />
<div>
<b>{{ n.title }}</b>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh">
<el-form label-position="top">
<el-form-item label="教学班" required>
<el-select v-model="form.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="申请类型" required>
<el-segmented v-model="form.type" :options="[
{ label: '调课', value: 'Reschedule' },
{ label: '停课', value: 'Cancel' },
{ label: '补课', value: 'Makeup' },
{ label: '代课', value: 'Substitute' },
]" />
</el-form-item>
<template v-if="showDate(form.type)">
<el-form-item label="补课日期" required>
<el-date-picker v-model="form.targetDate" type="date" value-format="YYYY-MM-DD" placeholder="选择补课日期" />
</el-form-item>
</template>
<template v-if="showTypeFields(form.type)">
<div class="form-grid three">
<el-form-item label="星期">
<el-select v-model="form.dayOfWeek" placeholder="选择">
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="form.startPeriod">
<el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
</el-form-item>
</div>
<el-form-item label="教室(可选)">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
<el-option v-for="r in []" :key="r" /> <!-- classrooms loaded separately if needed -->
</el-select>
</el-form-item>
</template>
<template v-if="showSub(form.type)">
<el-form-item label="代课教师" required>
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
<el-option v-for="t in []" :key="t" /> <!-- teachers loaded separately if needed -->
</el-select>
</el-form-item>
</template>
<template v-if="showCancel(form.type)">
<div class="form-grid two">
<el-form-item label="停课周次">
<el-input-number v-model="form.cancelWeek" :min="1" :max="20" placeholder="第几周" />
</el-form-item>
<el-form-item label="停课日期">
<el-date-picker v-model="form.cancelDate" type="date" value-format="YYYY-MM-DD" placeholder="或选择日期" />
</el-form-item>
</div>
</template>
<el-form-item label="申请原因" required>
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请详细说明调停课原因" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.submit">提交后直接送审</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="submit">提交申请</el-button>
</template>
</el-dialog>
<!-- Reject dialog -->
<el-dialog v-model="rejectDialog" title="退回申请" width="500px">
<el-form label-position="top">
<el-form-item label="退回意见">
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请说明退回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="danger" @click="doReject">确认退回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.adj-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.adj-list { display: grid; gap: 12px; }
.adj-card { background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px 20px; }
.adj-card header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.adj-card header h3 { font-size: 15px; margin: 4px 0; }
.adj-card header p { font-size: 12px; color: var(--muted); margin: 0; }
.adj-card header span { font-size: 11px; color: var(--muted); text-transform: uppercase; }
.adj-tags { display: flex; gap: 6px; flex-shrink: 0; }
.adj-body { margin: 12px 0; font-size: 13px; }
.adj-details { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--muted); font-size: 12px; margin-top: 8px; }
.adj-meta { font-size: 11px; color: var(--muted); margin-top: 8px; }
.adj-card footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #ebeef5; padding-top: 12px; }
.adj-card.review { border-left: 4px solid #e6a23c; }
.adj-card.draft { border-left: 4px solid #909399; }
.adj-card.submitted { border-left: 4px solid #e6a23c; }
.adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; }
.notif-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; cursor: pointer; }
.notif-card.unread { background: #ecf5ff; border-color: #b3d8ff; }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 6px; }
.notif-card b { font-size: 14px; display: block; margin-bottom: 4px; }
.notif-card p { font-size: 13px; color: #606266; margin: 0; }
.notif-card small { font-size: 11px; color: var(--muted); }
.notif-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
</style>
+3 -1
View File
@@ -596,6 +596,7 @@ async function loadForceEligibleStudents(page = eligiblePage.value) {
keyword: studentKeyword.value.trim() || undefined,
page,
pageSize: 20,
forceMode: true,
},
},
)
@@ -1148,7 +1149,8 @@ onMounted(async () => {
>代选学生</el-button>
<el-button
v-if="isManager"
type="warning"
type="danger"
plain
:icon="Plus"
@click="openForceEnrollment"
>强制选课</el-button>