feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

@@ -0,0 +1,292 @@
<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>