Files
Academic-Affairs-System/web/src/views/FreeClassroomsView.vue
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[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)
2026-07-26 11:14:05 +08:00

540 lines
13 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 { 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>