Files
Academic-Affairs-System/web/src/views/CourseAdjustmentsView.vue
T
2026-07-27 08:59:02 +08:00

516 lines
24 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 { Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
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 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 sessionOptions = ref<any[]>([])
const classrooms = ref<any[]>([])
const periods = ref<any[]>([])
const sessionTask = ref<any>(null)
const sessionLoading = ref(false)
const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine')
const form = reactive({
teachingTaskId: undefined as string | undefined,
sourceKey: undefined as string | undefined,
sourceScheduleEntryId: undefined as string | undefined,
sourceWeek: undefined as number | 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, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
const sourceRequired = computed(() => ['Reschedule', 'Cancel', 'Makeup'].includes(form.type))
const selectedSession = computed(() =>
sessionOptions.value.find(item => sessionKey(item) === form.sourceKey))
function periodOptions() {
if (periods.value.length) {
return periods.value.map(item => ({
value: item.periodNumber,
label: `第 ${item.periodNumber} 节 · ${item.name} ${item.startsAt}${item.endsAt}`,
}))
}
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
}
function sessionKey(session: any) {
return `${session.scheduleEntryId}:${session.week}`
}
function weekdayLabel(day: number) {
return ['','一','二','三','四','五','六','日'][day]
}
function sessionLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const room = session.classroomName
? ` · ${session.buildingName ?? ''}${session.classroomName}`
: ' · 无固定教室'
return `第 ${session.week} 周 · ${session.date}${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}${end}${room}`
}
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
}
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
sourceKey: undefined,
sourceScheduleEntryId: undefined,
sourceWeek: 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 loadSessionOptions() {
sessionOptions.value = []
classrooms.value = []
periods.value = []
sessionTask.value = null
form.sourceKey = undefined
form.sourceScheduleEntryId = undefined
form.sourceWeek = undefined
if (!form.teachingTaskId) return
sessionLoading.value = true
try {
const data = (await http.get(
`/course-adjustments/tasks/${form.teachingTaskId}/session-options`,
)).data
sessionTask.value = data.task
sessionOptions.value = data.sessions
classrooms.value = data.classrooms
periods.value = data.periods
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
sessionLoading.value = false
}
}
function selectSession() {
const session = selectedSession.value
if (!session) return
form.sourceScheduleEntryId = session.scheduleEntryId
form.sourceWeek = session.week
form.periodCount = session.periodCount
form.classroomId = session.classroomId ?? undefined
}
async function submit() {
if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return }
if (sourceRequired.value && !selectedSession.value) { ElMessage.warning('请选择要处理的原课次'); return }
if (selectedSession.value?.hasExistingAdjustment) { ElMessage.warning('该课次已有调停课记录,请选择其他课次'); return }
if (['Reschedule', 'Makeup'].includes(form.type) && !form.targetDate) { ElMessage.warning('请选择目标日期'); return }
if (['Reschedule', 'Makeup'].includes(form.type) && !form.startPeriod) { ElMessage.warning('请选择目标节次'); return }
if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return }
try {
await http.post('/course-adjustments', {
...form,
sourceKey: undefined,
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 loadTaskOptions() {
if (!isTeacher.value && isManager.value) return
try {
tasks.value = (await http.get('/course-adjustments/task-options', {
params: { academicTermId: termId.value || undefined },
})).data
} catch (error) {
tasks.value = []
ElMessage.error(apiErrorMessage(error))
}
}
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)) }
}
onMounted(async () => {
try {
const termRes = await http.get('/base-data/terms')
terms.value = termRes.data
termId.value = defaultAcademicTermId(terms.value)
await loadTaskOptions()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await load()
})
function changeType() {
form.targetDate = ''
form.startPeriod = undefined
form.classroomId = selectedSession.value?.classroomId ?? undefined
}
function showTarget(type: string) {
return type === 'Reschedule' || type === 'Makeup'
}
function showSub(type: string) { return type === 'Substitute' }
</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-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(); loadTaskOptions()">
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select>
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
]" />
</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 v-if="adj.sourceDate" class="adjustment-route">
<div><small>原课次</small><b> {{ adj.sourceWeek }} · {{ adj.sourceDate }} · {{ adj.sourceStartPeriod }}{{ adj.sourceStartPeriod + adj.sourcePeriodCount - 1 }} </b><span>{{ adj.sourceClassroomName || '无固定教室' }}</span></div>
<i>{{ adj.type === 'Cancel' ? '×' : '→' }}</i>
<div v-if="adj.type !== 'Cancel'"><small>{{ adj.type === 'Makeup' ? '新增补课' : '调整至' }}</small><b>{{ adj.targetDate }} · {{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }} </b><span>{{ adj.classroomName ? `${adj.buildingName} ${adj.classroomName}` : adj.sourceClassroomName || '无固定教室' }}</span></div>
<div v-else><small>处理结果</small><b>仅停掉本次课程</b><span>其他周次保持不变</span></div>
</div>
<div class="adj-details">
<span v-if="!adj.sourceDate && adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="!adj.sourceDate && adj.dayOfWeek"><b>星期</b>{{ weekdayLabel(adj.dayOfWeek) }}</span>
<span v-if="!adj.sourceDate && adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="!adj.sourceDate && adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</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 v-if="adj.sourceDate" class="adjustment-route">
<div><small>原课次</small><b> {{ adj.sourceWeek }} · {{ adj.sourceDate }} · {{ adj.sourceStartPeriod }}{{ adj.sourceStartPeriod + adj.sourcePeriodCount - 1 }} </b><span>{{ adj.sourceClassroomName || '无固定教室' }}</span></div>
<i>{{ adj.type === 'Cancel' ? '×' : '→' }}</i>
<div v-if="adj.type !== 'Cancel'"><small>{{ adj.type === 'Makeup' ? '新增补课' : '调整至' }}</small><b>{{ adj.targetDate }} · {{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }} </b><span>{{ adj.classroomName ? `${adj.buildingName} ${adj.classroomName}` : adj.sourceClassroomName || '无固定教室' }}</span></div>
<div v-else><small>处理结果</small><b>仅停掉本次课程</b><span>其他周次保持不变</span></div>
</div>
<div class="adj-details">
<span v-if="!adj.sourceDate && adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="!adj.sourceDate && adj.dayOfWeek"><b>星期</b>{{ weekdayLabel(adj.dayOfWeek) }}</span>
<span v-if="!adj.sourceDate && adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="!adj.sourceDate && adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</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>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="发起单次课程调整" width="760px" top="4vh" class="adjustment-dialog">
<el-form label-position="top" class="adjustment-form">
<section class="adjustment-step">
<header><b>1</b><div><h3>选择业务类型</h3><p>每次申请只处理一个具体课次</p></div></header>
<el-form-item label="申请类型" required>
<el-segmented v-model="form.type" :options="[
{ label: '调课', value: 'Reschedule' },
{ label: '停课', value: 'Cancel' },
{ label: '补课', value: 'Makeup' },
{ label: '代课', value: 'Substitute' },
]" @change="changeType" />
</el-form-item>
<el-alert
:title="form.type === 'Reschedule' ? '把原课次移到另一个日期和节次,原时间不再上课。' : form.type === 'Cancel' ? '只停掉选中的这一次课,不影响其他周次。' : form.type === 'Makeup' ? '保留原课次,另外增加一次补课。' : '为教学班增加代课教师。'"
type="info" :closable="false"
/>
</section>
<section class="adjustment-step">
<header><b>2</b><div><h3>锁定原课次</h3><p>从已发布课表中选择不再手填周次和星期</p></div></header>
<el-form-item label="教学班" required>
<el-select v-model="form.teachingTaskId" filterable placeholder="选择本人承担的教学班" @change="loadSessionOptions">
<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 v-if="sourceRequired" label="原课次" required>
<el-select
v-model="form.sourceKey" filterable :loading="sessionLoading"
placeholder="选择第几周、哪一天、哪一节课" @change="selectSession"
>
<el-option
v-for="session in sessionOptions" :key="sessionKey(session)"
:label="sessionLabel(session)" :value="sessionKey(session)"
:disabled="session.hasExistingAdjustment"
>
<span>{{ sessionLabel(session) }}</span>
<small v-if="session.hasExistingAdjustment" class="session-used">已有申请</small>
</el-option>
</el-select>
</el-form-item>
<el-empty v-if="form.teachingTaskId && sourceRequired && !sessionLoading && !sessionOptions.length" description="该教学班没有可用的已发布课次" :image-size="56" />
</section>
<section v-if="showTarget(form.type)" class="adjustment-step target-step">
<header><b>3</b><div><h3>{{ form.type === 'Reschedule' ? '安排调课后课次' : '安排补课课次' }}</h3><p>课程时长沿用原课次系统会在审批时检查教师班级和教室冲突</p></div></header>
<div class="source-target-strip" v-if="selectedSession">
<div><span>原课次</span><strong>{{ sessionLabel(selectedSession) }}</strong></div>
<i></i>
<div><span>目标课次</span><strong>{{ form.targetDate || '待选日期' }} · {{ form.startPeriod ? `第 ${form.startPeriod} 节起` : '待选节次' }}</strong></div>
</div>
<div class="form-grid two">
<el-form-item label="目标日期" required>
<el-date-picker
v-model="form.targetDate" type="date" value-format="YYYY-MM-DD"
:disabled-date="(date: Date) => sessionTask && (date < new Date(`${sessionTask.termStartDate}T00:00:00`) || date > new Date(`${sessionTask.termEndDate}T23:59:59`))"
placeholder="选择本学期内日期"
/>
</el-form-item>
<el-form-item label="目标起始节次" required>
<el-select v-model="form.startPeriod" placeholder="选择节次">
<el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</div>
<el-form-item label="目标教室">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空则沿用原教室">
<el-option
v-for="room in classrooms" :key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id"
/>
</el-select>
</el-form-item>
</section>
<template v-if="showSub(form.type)">
<section class="adjustment-step">
<header><b>3</b><div><h3>选择代课教师</h3><p>代课教师必须为在职教师。</p></div></header>
<el-form-item label="代课教师" required>
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师" />
</el-form-item>
</section>
</template>
<el-form-item label="申请原因" required class="reason-field">
<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; }
.adjustment-route {
display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr);
align-items: stretch; gap: 8px; margin: 12px 0; padding: 10px;
background: #f4f7f9; border: 1px solid #dfe7eb;
}
.adjustment-route > div { display: grid; gap: 4px; padding: 9px 11px; background: white; border-left: 3px solid var(--teal); }
.adjustment-route small { color: var(--muted); font-size: 10px; }
.adjustment-route b { font-size: 12px; color: var(--ink); }
.adjustment-route span { color: var(--muted); font-size: 11px; }
.adjustment-route > i { align-self: center; color: var(--indigo); font: 700 20px/1 Consolas, monospace; text-align: center; }
.adjustment-form { display: grid; gap: 14px; }
.adjustment-step { padding: 16px; border: 1px solid var(--line); background: #fbfcfd; }
.adjustment-step > header { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 14px; }
.adjustment-step > header > b {
display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 26px;
color: white; background: var(--indigo); font: 700 12px/1 Consolas, monospace;
}
.adjustment-step > header h3 { margin: 1px 0 3px; font-size: 14px; }
.adjustment-step > header p { margin: 0; color: var(--muted); font-size: 11px; }
.adjustment-step .el-form-item:last-child { margin-bottom: 0; }
.target-step { border-color: #b9d9d4; background: #f4faf8; }
.source-target-strip { display: grid; grid-template-columns: 1fr 34px 1fr; align-items: stretch; gap: 8px; margin-bottom: 14px; }
.source-target-strip > div { display: grid; gap: 5px; padding: 11px; background: white; border: 1px solid #d9e3e6; }
.source-target-strip span { color: var(--muted); font-size: 10px; }
.source-target-strip strong { font-size: 11px; line-height: 1.55; }
.source-target-strip > i { align-self: center; color: var(--teal); font: 700 20px/1 Consolas, monospace; text-align: center; }
.session-used { float: right; color: #b34e48; }
.reason-field { padding: 14px 16px 0; border-top: 1px solid var(--line); }
@media (max-width: 680px) {
.adjustment-route, .source-target-strip { grid-template-columns: 1fr; }
.adjustment-route > i, .source-target-strip > i { transform: rotate(90deg); padding: 2px; }
.adjustment-step { padding: 13px; }
}
</style>