切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1) 桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211) 归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。 已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
540 lines
13 KiB
Vue
540 lines
13 KiB
Vue
<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'
|
||
import { academicTermLabel, academicTermOptionClass } from '../utils/academicTerms'
|
||
|
||
interface TermOption {
|
||
id: string
|
||
name: string
|
||
startDate: string
|
||
endDate: string
|
||
isCurrent: boolean
|
||
isArchived: 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="academicTermLabel(term)"
|
||
:value="term.id"
|
||
:class="academicTermOptionClass(term)"
|
||
: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>
|