排课版本新建、编辑、复制、发布、归档和删除。

周课表视图,支持12节课、单双周和起止周。
自动校验教师、行政班、教室、时间段冲突。
校验教室容量、教学任务状态和学期范围。
SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。
Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。
桌面端和390px移动端页面均已验收。
This commit is contained in:
2026-07-24 14:27:01 +08:00 Unverified
parent 628944f64d
commit 0b463fa4f2
19 changed files with 2950 additions and 9 deletions
+10 -1
View File
@@ -8,6 +8,7 @@ import {
Operation,
Reading,
Tickets,
Calendar,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -27,6 +28,7 @@ const pageTitle = computed(() => {
courses: '课程库',
curriculum: '培养方案',
'teaching-tasks': '教学任务',
schedules: '排课与课表',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -98,6 +100,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><Tickets /></el-icon>
<template #title>教学任务</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
index="/schedules"
>
<el-icon><Calendar /></el-icon>
<template #title>排课与课表</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -106,7 +115,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>主数据与培养方案正在运行</p>
<p>主数据培养方案与教学运行已就绪</p>
</div>
</aside>
+6
View File
@@ -51,6 +51,12 @@ const router = createRouter({
component: () => import('../views/TeachingTasksView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('../views/SchedulesView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'users',
name: 'users',
+44
View File
@@ -205,6 +205,42 @@ button { cursor: pointer; }
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
.task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; }
.muted-action { color: #9aa1ae; font-size: 11px; }
.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; }
.schedule-toolbar > .el-select { width: 245px; align-self: center; }
.schedule-version-strip { min-width: 0; display: flex; align-items: stretch; gap: 8px; overflow-x: auto; }
.schedule-version-strip > button { min-width: 120px; padding: 10px 14px; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; text-align: left; border: 1px solid var(--line); background: #fafbfc; }
.schedule-version-strip > button:hover { background: white; }
.schedule-version-strip > button.active { color: white; border-color: var(--indigo); background: var(--indigo); }
.schedule-version-strip b { font: 700 14px/1.2 Consolas, monospace; }
.schedule-version-strip span, .schedule-version-strip small { font-size: 9px; }
.schedule-version-strip small { grid-column: 1 / -1; color: #9199a8; }
.schedule-version-strip > button.active small { color: #cbd3eb; }
.schedule-version-strip > span { align-self: center; color: var(--muted); font-size: 11px; }
.schedule-sheet { min-width: 0; border: 1px solid var(--line); background: white; }
.schedule-sheet-head { padding: 21px 22px; display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); }
.schedule-sheet-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .07em; }
.schedule-sheet-head h3 { margin: 6px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; }
.schedule-sheet-head p { margin: 0; color: var(--muted); font-size: 10px; }
.schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); }
.schedule-search .el-input { width: 320px; }
.schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; }
.timetable-scroll { width: 100%; overflow: auto; }
.timetable-grid { min-width: 1120px; display: grid; grid-template-columns: 72px repeat(7, minmax(145px, 1fr)); }
.timetable-corner, .timetable-day { min-height: 45px; display: grid; place-items: center; color: #555f72; background: #f4f6f9; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); font-size: 11px; font-weight: 650; }
.timetable-period { min-height: 88px; padding: 14px 8px; display: grid; align-content: start; justify-items: center; gap: 5px; color: var(--indigo); background: #fafbfc; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.timetable-period b { font: 700 17px/1 Consolas, monospace; }
.timetable-period span { color: var(--muted); font-size: 9px; }
.timetable-cell { min-width: 0; min-height: 88px; padding: 5px; display: grid; align-content: start; gap: 5px; position: relative; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: white; }
.timetable-cell.editable:hover { background: #f7fbfa; }
.cell-add { margin: auto; color: transparent; font-size: 9px; }
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
.schedule-card.readonly { cursor: default; }
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
.schedule-card small, .schedule-card i { overflow: hidden; color: #c6cfe6; font-size: 8px; font-style: normal; white-space: nowrap; text-overflow: ellipsis; }
.schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; }
.schedule-card > button:hover { color: white; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
@@ -288,6 +324,14 @@ button { cursor: pointer; }
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
.task-summary > div { width: 100%; }
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
.schedule-toolbar { display: block; }
.schedule-toolbar > .el-select { width: 100%; }
.schedule-version-strip { margin-top: 10px; }
.schedule-sheet-head { display: block; }
.schedule-sheet-head .plan-actions { margin-top: 15px; }
.schedule-search { flex-wrap: wrap; }
.schedule-search .el-input { width: 100%; }
.schedule-search > span { width: 100%; margin-left: 0; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
+7 -2
View File
@@ -96,18 +96,23 @@ onMounted(async () => {
<b>{{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班</b>
<i class="done">已建立</i>
</div>
<div>
<span>排课课表</span>
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>学期教学任务已可发布下一步进入时间教室冲突校验与课表发布</p>
<p>排课与课表发布链路已就绪下一步进入学生选课轮次容量与候补管理</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span class="active">培养方案</span>
<span class="active">教学任务</span>
<span class="active">排课课表</span>
</div>
</article>
</section>
+365
View File
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const classrooms = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const planDialog = ref(false)
const cloneDialog = ref(false)
const entryDialog = ref(false)
const editingPlanId = ref('')
const editingEntryId = ref('')
const keyword = ref('')
const termId = ref<string | undefined>()
const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const entryForm = reactive<Record<string, any>>({})
const weekdays = [
{ value: 1, label: '星期一' },
{ value: 2, label: '星期二' },
{ value: 3, label: '星期三' },
{ value: 4, label: '星期四' },
{ value: 5, label: '星期五' },
{ value: 6, label: '星期六' },
{ value: 7, label: '星期日' },
]
const periods = Array.from({ length: 12 }, (_, index) => index + 1)
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const patternLabels: Record<string, string> = {
All: '每周',
Odd: '单周',
Even: '双周',
}
const isDraft = computed(() => selected.value?.status === 'Draft')
const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? []
return (selected.value?.entries ?? []).filter((entry: any) =>
[
entry.taskNumber,
entry.taskName,
entry.courseCode,
entry.courseName,
entry.classroomName,
...entry.teacherNames,
...entry.classNames,
].some((value) => String(value).toLowerCase().includes(text)),
)
})
function entriesAt(day: number, period: number) {
return filteredEntries.value.filter(
(entry: any) => entry.dayOfWeek === day && entry.startPeriod === period,
)
}
async function loadPlans(keepSelection = true) {
loading.value = true
try {
plans.value = (await http.get('/schedules/plans', {
params: { academicTermId: termId.value },
})).data
const id = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (id && plans.value.some((item) => item.id === id)) await loadDetail(id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadDetail(id: string) {
detailLoading.value = true
try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function openPlan(plan?: any) {
editingPlanId.value = plan?.id ?? ''
Object.assign(planForm, {
academicTermId: plan?.academicTermId ?? termId.value,
name: plan?.name ?? '',
version: plan?.version ?? `V${plans.value.length + 1}`,
notes: plan?.notes ?? '',
})
planDialog.value = true
}
async function savePlan() {
if (!planForm.academicTermId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写学期、版本名称和版本号。')
return
}
try {
if (editingPlanId.value) {
await http.put(`/schedules/plans/${editingPlanId.value}`, planForm)
} else {
await http.post('/schedules/plans', planForm)
}
planDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openClone() {
Object.assign(cloneForm, {
name: `${selected.value.name}(调整版)`,
version: `V${plans.value.length + 1}`,
})
cloneDialog.value = true
}
async function clonePlan() {
try {
await http.post(`/schedules/plans/${selected.value.id}/clone`, cloneForm)
cloneDialog.value = false
ElMessage.success('已复制为可调整的草稿版本')
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm(
'系统将再次检查全部教师、行政班和教室冲突;发布后本版本锁定,并归档旧课表。',
'发布课表',
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
)
await http.post(`/schedules/plans/${selected.value.id}/publish`)
ElMessage.success('课表已发布')
await loadPlans()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function deletePlan() {
try {
await ElMessageBox.confirm('删除该草稿及全部排课条目?', '删除排课草稿', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}`)
await loadPlans(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openEntry(entry?: any, day?: number, period?: number) {
editingEntryId.value = entry?.id ?? ''
Object.assign(entryForm, {
teachingTaskId: entry?.teachingTaskId,
classroomId: entry?.classroomId,
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
startPeriod: entry?.startPeriod ?? period ?? 1,
periodCount: entry?.periodCount ?? 2,
startWeek: entry?.startWeek ?? 1,
endWeek: entry?.endWeek ?? 16,
weekPattern: entry?.weekPattern ?? 'All',
notes: entry?.notes ?? '',
})
entryDialog.value = true
}
async function saveEntry() {
if (!entryForm.teachingTaskId || !entryForm.classroomId) {
ElMessage.warning('请选择教学任务和教室。')
return
}
try {
const base = `/schedules/plans/${selected.value.id}/entries`
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
else await http.post(base, entryForm)
entryDialog.value = false
ElMessage.success(editingEntryId.value ? '排课已调整' : '排课已添加')
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteEntry(entry: any) {
try {
await ElMessageBox.confirm(`移除“${entry.taskName}”的这条安排?`, '移除排课', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}/entries/${entry.id}`)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [termRes, taskRes, classroomRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100, status: 'Published' } }),
http.get('/base-data/classrooms'),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
termId.value = terms.value.find((item) => item.isCurrent)?.id
await loadPlans(false)
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">TIMETABLE BOARD</span>
<h2>排课与课表</h2>
<p>在发布前消除教师行政班与教室冲突并保留每次发布的版本</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建排课版本</el-button>
</section>
<section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="loadPlans(false)">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<div class="schedule-version-strip">
<button
v-for="plan in plans"
:key="plan.id"
type="button"
:class="{ active: selected?.id === plan.id }"
@click="loadDetail(plan.id)"
>
<b>{{ plan.version }}</b>
<span>{{ statusLabels[plan.status] }}</span>
<small>{{ plan.entryCount }} 条安排</small>
</button>
<span v-if="plans.length === 0">该学期还没有排课版本</span>
</div>
</section>
<section v-if="selected" v-loading="detailLoading" class="schedule-sheet">
<header class="schedule-sheet-head">
<div>
<span>{{ selected.termName }} · {{ selected.version }}</span>
<h3>{{ selected.name }}</h3>
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" @click="openPlan(selected)">编辑版本</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制调整</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button>
</div>
</header>
<div class="schedule-search">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>
<span>单双周和周次范围显示在课程卡片内</span>
</div>
<div class="timetable-scroll">
<div class="timetable-grid">
<div class="timetable-corner">节次</div>
<div v-for="day in weekdays" :key="day.value" class="timetable-day">{{ day.label }}</div>
<template v-for="period in periods" :key="period">
<div class="timetable-period">
<b>{{ period }}</b>
<span> {{ period }} </span>
</div>
<div
v-for="day in weekdays"
:key="`${day.value}-${period}`"
class="timetable-cell"
:class="{ editable: isDraft }"
@dblclick="isDraft && openEntry(undefined, day.value, period)"
>
<article
v-for="entry in entriesAt(day.value, period)"
:key="entry.id"
class="schedule-card"
:class="{ readonly: !isDraft }"
@click="isDraft && openEntry(entry)"
>
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
<b>{{ entry.courseName }}</b>
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName }}</small>
<i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i>
<button v-if="isDraft" type="button" @click.stop="deleteEntry(entry)">×</button>
</article>
<span v-if="isDraft && entriesAt(day.value, period).length === 0" class="cell-add">双击添加</span>
</div>
</template>
</div>
</div>
</section>
<el-empty v-else description="请选择或新建一个排课版本" />
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
<el-form label-position="top">
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog>
<el-dialog v-model="cloneDialog" title="复制排课版本" width="520px">
<el-form label-position="top">
<el-form-item label="新版本名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<el-form-item label="新版本号" required><el-input v-model="cloneForm.version" /></el-form-item>
</el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
</el-dialog>
<el-dialog v-model="entryDialog" :title="editingEntryId ? '调整排课' : '添加排课'" width="680px">
<el-form label-position="top">
<el-form-item label="教学任务" required>
<el-select v-model="entryForm.teachingTaskId" filterable>
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="教室" required>
<el-select v-model="entryForm.classroomId" filterable>
<el-option v-for="item in classrooms" :key="item.id" :label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.capacity}人)`" :value="item.id" />
</el-select>
</el-form-item>
<div class="form-grid three">
<el-form-item label="星期"><el-select v-model="entryForm.dayOfWeek"><el-option v-for="day in weekdays" :key="day.value" :label="day.label" :value="day.value" /></el-select></el-form-item>
<el-form-item label="开始节次"><el-input-number v-model="entryForm.startPeriod" :min="1" :max="12" /></el-form-item>
<el-form-item label="连续节数"><el-input-number v-model="entryForm.periodCount" :min="1" :max="6" /></el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="开始周"><el-input-number v-model="entryForm.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="entryForm.endWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="单双周"><el-select v-model="entryForm.weekPattern"><el-option v-for="(label, value) in patternLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="entryForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="entryDialog = false">取消</el-button><el-button type="primary" @click="saveEntry">检查冲突并保存</el-button></template>
</el-dialog>
</div>
</template>