Files
ClassHelper/packages/web-admin/src/views/SchedulesView.vue
T

293 lines
9.8 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, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
buildWeekOptions,
formatWeekRange,
type ClassDto,
type CourseDto,
type CreateScheduleRequest,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const grid = ref<ScheduleWeekView | null>(null);
const rawSchedules = ref<ScheduleDto[]>([]);
const query = reactive({ classId: '', week: 1 });
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
for (const item of rawSchedules.value) {
const key = `${item.startTime}-${item.endTime}`;
const bucket = buckets.get(key) ?? [];
bucket.push(item);
buckets.set(key, bucket);
}
return [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, items]) => {
const [start, end] = key.split('-');
return { start: start ?? '', end: end ?? '', items };
});
});
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
}
async function loadBase(): Promise<void> {
const [classList, term] = await Promise.all([classApi.list(), dashboardApi.term()]);
classes.value = classList;
weekOptions.value = buildWeekOptions(term.maxWeek);
query.week = term.currentWeek;
if (!query.classId && classList.length > 0) query.classId = classList[0]?.id ?? '';
if (query.classId) await loadCourses();
}
async function loadCourses(): Promise<void> {
courses.value = query.classId ? await courseApi.list(query.classId) : [];
}
async function loadSchedules(): Promise<void> {
if (!query.classId) {
grid.value = null;
rawSchedules.value = [];
return;
}
loading.value = true;
try {
const [weekGrid, list] = await Promise.all([
scheduleApi.grid({ classId: query.classId, week: query.week }),
scheduleApi.list({ classId: query.classId, week: query.week }),
]);
grid.value = weekGrid;
rawSchedules.value = list;
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
await loadCourses();
await loadSchedules();
}
/* ------------------------------------------------------------ 新增 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({
courseId: '',
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
location: '',
weekStart: 1,
weekEnd: 20,
});
const rules: FormRules = {
courseId: [{ required: true, message: '请选择课程', trigger: 'change' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
};
function openCreate(): void {
if (!query.classId) {
ElMessage.warning('请先选择班级');
return;
}
editingId.value = null;
form.courseId = courses.value[0]?.id ?? '';
form.dayOfWeek = 1;
form.startTime = '08:00';
form.endTime = '08:45';
form.location = '';
form.weekStart = 1;
form.weekEnd = weekOptions.value.at(-1) ?? 20;
formVisible.value = true;
}
function openEdit(item: ScheduleDto): void {
editingId.value = item.id;
form.courseId = item.courseId;
form.dayOfWeek = item.dayOfWeek;
form.startTime = item.startTime;
form.endTime = item.endTime;
form.location = item.location ?? '';
form.weekStart = item.weekStart;
form.weekEnd = item.weekEnd;
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (form.startTime >= form.endTime) {
ElMessage.warning('结束时间必须晚于开始时间');
return;
}
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId: form.courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
};
if (editingId.value) {
await scheduleApi.update(editingId.value, payload);
ElMessage.success('课表已更新');
} else {
await scheduleApi.create(payload);
ElMessage.success('课表已新增');
}
formVisible.value = false;
await loadSchedules();
}
async function removeSchedule(item: ScheduleDto): Promise<void> {
await ElMessageBox.confirm(
`删除 ${WEEKDAY_LABELS[item.dayOfWeek]} ${item.startTime} ${item.course?.name ?? ''} 这节课?`,
'确认',
{
type: 'warning',
},
);
await scheduleApi.remove(item.id);
ElMessage.success('已删除');
await loadSchedules();
}
function onScheduleEvent(): void {
void loadSchedules();
}
onMounted(async () => {
await loadBase();
await loadSchedules();
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">课表管理</h2>
<p class="page-subtitle">按周次维护课程安排,支持单双周范围(起始周 - 结束周)</p>
</div>
<div class="toolbar">
<el-select
v-model="query.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.week" placeholder="周次" style="width: 130px" @change="loadSchedules">
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
</el-select>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增课表</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="toolbar">
<span>
第 {{ query.week }} 周课表
<span class="text-muted">(共 {{ rawSchedules.length }} 节课)</span>
</span>
</div>
</template>
<el-empty v-if="periods.length === 0" description="该周暂无课表,点击右上角新增" />
<el-table v-else :data="periods" border size="small">
<el-table-column label="节次" width="120" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" min-width="130">
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '-' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<div>
<el-button link type="primary" size="small" @click="openEdit(item)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeSchedule(item)">删除</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑课表' : '新增课表'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="课程" prop="courseId">
<el-select v-model="form.courseId" placeholder="选择课程" style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="form.dayOfWeek" style="width: 100%">
<el-option v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" :value="day" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-time-select
v-model="form.startTime"
start="07:00"
step="00:05"
end="21:00"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-time-select v-model="form.endTime" start="07:00" step="00:05" end="22:00" style="width: 100%" />
</el-form-item>
<el-form-item label="地点">
<el-input v-model="form.location" placeholder="例如 教学楼 A301" />
</el-form-item>
<el-form-item label="周次范围">
<el-input-number v-model="form.weekStart" :min="1" :max="30" />
<span style="margin: 0 8px">至</span>
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>