主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。 管理员课表查询中心支持班级、教师、场地三种课表。 超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。 增加周视图、日视图切换。 支持 Excel 导出和当前视图 PDF 导出。 学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。 空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Location, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
interface TermOption {
|
||||
id: string
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
isCurrent: boolean
|
||||
hasPublishedTimetable: boolean
|
||||
}
|
||||
|
||||
interface PlaceOption {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
campusId?: string
|
||||
}
|
||||
|
||||
interface TimeSlotOption {
|
||||
periodNumber: number
|
||||
name: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
interface FreeClassroom {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
capacity: number
|
||||
buildingId: string
|
||||
buildingName: string
|
||||
campusId: string
|
||||
campusName: string
|
||||
}
|
||||
|
||||
const weekdayOptions = [
|
||||
{ value: 1, label: '周一' },
|
||||
{ value: 2, label: '周二' },
|
||||
{ value: 3, label: '周三' },
|
||||
{ value: 4, label: '周四' },
|
||||
{ value: 5, label: '周五' },
|
||||
{ value: 6, label: '周六' },
|
||||
{ value: 7, label: '周日' },
|
||||
]
|
||||
const terms = ref<TermOption[]>([])
|
||||
const campuses = ref<PlaceOption[]>([])
|
||||
const buildings = ref<PlaceOption[]>([])
|
||||
const timeSlots = ref<TimeSlotOption[]>([])
|
||||
const classrooms = ref<FreeClassroom[]>([])
|
||||
const loading = ref(false)
|
||||
const searched = ref(false)
|
||||
const resultPage = ref(1)
|
||||
const pageSize = 48
|
||||
const form = reactive({
|
||||
academicTermId: '',
|
||||
week: 1,
|
||||
dayOfWeek: new Date().getDay() || 7,
|
||||
startPeriod: 1,
|
||||
periodCount: 1,
|
||||
campusId: '',
|
||||
buildingId: '',
|
||||
minimumCapacity: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const selectedTerm = computed(() =>
|
||||
terms.value.find((item) => item.id === form.academicTermId),
|
||||
)
|
||||
const filteredBuildings = computed(() =>
|
||||
buildings.value.filter((item) => !form.campusId || item.campusId === form.campusId),
|
||||
)
|
||||
const availableStartPeriods = computed(() =>
|
||||
timeSlots.value.filter((slot) =>
|
||||
timeSlots.value.some((candidate) =>
|
||||
candidate.periodNumber === slot.periodNumber + form.periodCount - 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
const pagedClassrooms = computed(() =>
|
||||
classrooms.value.slice((resultPage.value - 1) * pageSize, resultPage.value * pageSize),
|
||||
)
|
||||
const groupedRooms = computed(() => {
|
||||
const groups = new Map<string, { campusName: string; buildingName: string; rooms: FreeClassroom[] }>()
|
||||
for (const room of pagedClassrooms.value) {
|
||||
const key = `${room.campusId}:${room.buildingId}`
|
||||
const group = groups.get(key) ?? {
|
||||
campusName: room.campusName,
|
||||
buildingName: room.buildingName,
|
||||
rooms: [],
|
||||
}
|
||||
group.rooms.push(room)
|
||||
groups.set(key, group)
|
||||
}
|
||||
return [...groups.values()]
|
||||
})
|
||||
const querySummary = computed(() => {
|
||||
const day = weekdayOptions.find((item) => item.value === form.dayOfWeek)?.label ?? ''
|
||||
const end = form.startPeriod + form.periodCount - 1
|
||||
const periods = end === form.startPeriod
|
||||
? `第 ${form.startPeriod} 节`
|
||||
: `第 ${form.startPeriod}—${end} 节`
|
||||
return `第 ${form.week} 周 · ${day} · ${periods}`
|
||||
})
|
||||
|
||||
function calculateCurrentWeek(term?: TermOption) {
|
||||
if (!term) return 1
|
||||
const start = new Date(`${term.startDate}T00:00:00`)
|
||||
const today = new Date()
|
||||
const week = Math.floor((today.getTime() - start.getTime()) / 604800000) + 1
|
||||
return Math.min(30, Math.max(1, week))
|
||||
}
|
||||
|
||||
function onCampusChanged() {
|
||||
if (!filteredBuildings.value.some((item) => item.id === form.buildingId)) {
|
||||
form.buildingId = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(academicTermId?: string) {
|
||||
try {
|
||||
const { data } = await http.get('/timetables/free-classrooms/options', {
|
||||
params: academicTermId ? { academicTermId } : undefined,
|
||||
})
|
||||
terms.value = data.terms
|
||||
campuses.value = data.campuses
|
||||
buildings.value = data.buildings
|
||||
timeSlots.value = data.timeSlots
|
||||
if (!form.academicTermId) {
|
||||
form.academicTermId = data.selectedTermId ?? ''
|
||||
form.week = calculateCurrentWeek(
|
||||
terms.value.find((item) => item.id === form.academicTermId),
|
||||
)
|
||||
}
|
||||
if (!availableStartPeriods.value.some(
|
||||
(item) => item.periodNumber === form.startPeriod,
|
||||
)) {
|
||||
form.startPeriod = availableStartPeriods.value[0]?.periodNumber ?? 1
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onTermChanged() {
|
||||
form.week = calculateCurrentWeek(selectedTerm.value)
|
||||
classrooms.value = []
|
||||
searched.value = false
|
||||
await loadOptions(form.academicTermId)
|
||||
}
|
||||
|
||||
async function searchRooms() {
|
||||
if (!form.academicTermId) {
|
||||
ElMessage.warning('请先选择学期。')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get('/timetables/free-classrooms', {
|
||||
params: {
|
||||
academicTermId: form.academicTermId,
|
||||
week: form.week,
|
||||
dayOfWeek: form.dayOfWeek,
|
||||
startPeriod: form.startPeriod,
|
||||
periodCount: form.periodCount,
|
||||
campusId: form.campusId || undefined,
|
||||
buildingId: form.buildingId || undefined,
|
||||
minimumCapacity: form.minimumCapacity,
|
||||
},
|
||||
})
|
||||
classrooms.value = data.items
|
||||
resultPage.value = 1
|
||||
searched.value = true
|
||||
} catch (error) {
|
||||
classrooms.value = []
|
||||
searched.value = false
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="free-room-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">STUDENT SERVICE · 空间查询</span>
|
||||
<h2>空闲教室查询</h2>
|
||||
<p>依据已发布课表,查询指定周次和连续节次内没有排课的教学场所。</p>
|
||||
</div>
|
||||
<el-tag type="success" effect="plain">仅使用正式课表</el-tag>
|
||||
</header>
|
||||
|
||||
<el-card shadow="never" class="query-card">
|
||||
<el-form label-position="top" class="query-form">
|
||||
<el-form-item label="学期">
|
||||
<el-select v-model="form.academicTermId" @change="onTermChanged">
|
||||
<el-option
|
||||
v-for="term in terms"
|
||||
:key="term.id"
|
||||
:label="term.name"
|
||||
:value="term.id"
|
||||
:disabled="!term.hasPublishedTimetable"
|
||||
>
|
||||
<span>{{ term.name }}</span>
|
||||
<small v-if="!term.hasPublishedTimetable">尚未发布课表</small>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="周次">
|
||||
<el-input-number v-model="form.week" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="星期">
|
||||
<el-select v-model="form.dayOfWeek">
|
||||
<el-option
|
||||
v-for="item in weekdayOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始节次">
|
||||
<el-select v-model="form.startPeriod">
|
||||
<el-option
|
||||
v-for="slot in availableStartPeriods"
|
||||
:key="slot.periodNumber"
|
||||
:label="slot.startTime
|
||||
? `${slot.name}(${slot.startTime.slice(0, 5)}—${slot.endTime.slice(0, 5)})`
|
||||
: slot.name"
|
||||
:value="slot.periodNumber"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="连续节数">
|
||||
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
|
||||
</el-form-item>
|
||||
<el-form-item label="校区">
|
||||
<el-select v-model="form.campusId" clearable placeholder="全部校区" @change="onCampusChanged">
|
||||
<el-option
|
||||
v-for="campus in campuses"
|
||||
:key="campus.id"
|
||||
:label="campus.name"
|
||||
:value="campus.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="教学楼">
|
||||
<el-select v-model="form.buildingId" clearable placeholder="全部教学楼">
|
||||
<el-option
|
||||
v-for="building in filteredBuildings"
|
||||
:key="building.id"
|
||||
:label="building.name"
|
||||
:value="building.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="至少容纳人数">
|
||||
<el-input-number
|
||||
v-model="form.minimumCapacity"
|
||||
:min="0"
|
||||
:max="10000"
|
||||
placeholder="不限"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="query-action">
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="searchRooms">
|
||||
查询空闲教室
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<section v-loading="loading" class="result-panel">
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<span>查询条件</span>
|
||||
<h3>{{ querySummary }}</h3>
|
||||
</div>
|
||||
<div v-if="searched" class="result-count">
|
||||
<strong>{{ classrooms.length }}</strong>
|
||||
<span>间空闲教室</span>
|
||||
</div>
|
||||
<el-button v-if="searched" text :icon="Refresh" @click="searchRooms">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="searched && groupedRooms.length">
|
||||
<div class="building-groups">
|
||||
<article v-for="group in groupedRooms" :key="`${group.campusName}-${group.buildingName}`">
|
||||
<header>
|
||||
<el-icon><Location /></el-icon>
|
||||
<div>
|
||||
<small>{{ group.campusName }}</small>
|
||||
<h4>{{ group.buildingName }}</h4>
|
||||
</div>
|
||||
<span>{{ group.rooms.length }} 间</span>
|
||||
</header>
|
||||
<div class="room-grid">
|
||||
<div v-for="room in group.rooms" :key="room.id" class="room-item">
|
||||
<b>{{ room.name }}</b>
|
||||
<span>{{ room.code }}</span>
|
||||
<em>容纳 {{ room.capacity }} 人</em>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="classrooms.length > pageSize"
|
||||
v-model:current-page="resultPage"
|
||||
class="result-pagination"
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:page-size="pageSize"
|
||||
:total="classrooms.length"
|
||||
/>
|
||||
</template>
|
||||
<el-empty
|
||||
v-else-if="searched"
|
||||
description="当前条件下没有空闲教室,可尝试缩短连续节数或调整场地范围。"
|
||||
/>
|
||||
<el-empty v-else description="设置条件后查询空闲教室" />
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.free-room-page {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.result-heading,
|
||||
.result-count,
|
||||
.building-groups article > header,
|
||||
.room-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #17867c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 5px;
|
||||
color: #183b56;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
margin-top: 8px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
border-color: #dce6eb;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 2px 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.query-form :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.query-form :deep(.el-select),
|
||||
.query-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.query-form small {
|
||||
float: right;
|
||||
margin-left: 12px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.query-action {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.query-action .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
min-height: 260px;
|
||||
padding: 22px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.result-heading {
|
||||
gap: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #edf2f5;
|
||||
}
|
||||
|
||||
.result-heading > div:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-heading span,
|
||||
.building-groups small,
|
||||
.room-item span {
|
||||
color: #718096;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-heading h3 {
|
||||
margin-top: 3px;
|
||||
color: #24445d;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.result-count strong {
|
||||
color: #16867c;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.building-groups {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.building-groups article {
|
||||
border: 1px solid #dfe8ec;
|
||||
}
|
||||
|
||||
.building-groups article > header {
|
||||
gap: 10px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid #e7eef1;
|
||||
background: #f5faf9;
|
||||
color: #17867c;
|
||||
}
|
||||
|
||||
.building-groups article > header div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.building-groups h4 {
|
||||
margin-top: 2px;
|
||||
color: #24445d;
|
||||
}
|
||||
|
||||
.building-groups article > header > span {
|
||||
color: #17867c;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.room-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 1px;
|
||||
background: #e7eef1;
|
||||
}
|
||||
|
||||
.room-item {
|
||||
min-height: 92px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.room-item b {
|
||||
color: #24445d;
|
||||
}
|
||||
|
||||
.room-item em {
|
||||
margin-top: auto;
|
||||
color: #17867c;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.query-form {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-heading,
|
||||
.result-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user