feat(island,homework,schedule): 灵动岛点开修复+胶囊类型汇总 · 作业看板与未交名单 · 单双周课表与 ClassIsland 课程表导入

一、灵动岛(用户反馈的三处"点不开"全部修复)
- 非紧急叫人:上课时段仍以胶囊保持可见、可点开(不再只进队列导致屏幕上什么都没有);
  新增 isOpenable():紧急消息 + 任何"叫人"都允许点开,但普通叫人不会自动展开打断课堂。
- 紧急叫人收起后再点:命中改为"主进程按光标位置兜底校正"(上一轮的 island:set-hit-rect 轮询),
  本轮补齐叫人类专项回归(展开 → 收起 → 再次点开)。
- 设置页预览:新增 preview 通道 —— 示例岛直接展开、失焦不收起、30 秒自行消失、关掉不残留。
- 收起态胶囊汇总消息类型:多条时显示「新消息:叫人/作业/通知(共 3 条)」+「点击查看」+ 类型小标签。
- 顺带修掉两个真问题:胶囊态收到新消息会把上一条静默顶掉(现在一律回队);
  syncWindow 的上课时段守卫只认"紧急"导致普通叫人被藏掉。
- 性能:岛体矩形上报限流(~10 次/秒 + 尾随补发),修掉每帧 IPC 导致的开合采样骤降。

二、作业(学生端 + Web 教师端)
- 新增**看板模式**:按科目分卡片、点卡片放大全屏、工具栏可切「看板/列表」、
  开关「显示时间」、拖「看板字号」(11–28px),偏好持久化到客户端配置 homeworkBoard。
- 去掉「全班标记完成」,改为**未交名单**:勾选没交的同学,其余自动标记已交;
  新增 GET/PATCH /api/homeworks/:id/submissions(教师与班级设备可用,普通学生 403)。

三、课表单双周 + ClassIsland 课程表导入
- Schedule 新增 weekParity(ALL/ODD/EVEN)+ 迁移;周视图与上课判定按「第 1 周=单周」过滤,
  历史数据默认 ALL 行为不变;Web 手动新增/编辑课表可选单双周,学生端课表打「单周/双周」标签。
- 修复既有 P0 bug:ClassIsland 真实档案里 TimeLayouts 是 "Guid → { Name, Layouts }" 字典,
  旧解析器只认数组/.items,导致**真实导出完全导不进来**;现已按真实形态解析(并兼容旧写法),
  同时补上 TimeType=3(行动)映射。
- 新增 ClassIsland 课程表(ClassPlan)导入:解析 TimeRule.WeekCountDiv/WeekCountDivTotal → 单双周,
  Classes[i] 对齐第 i 个 TimeType=0 的时间点,Subjects 映射科目(缺失自动补建课程),
  星期 0=周日 换算为 1=周一 … 7=周日;replace/merge 两种模式,预览只解析不写库。
- 新增维护脚本 packages/server/scripts/apply-parity-column.cjs(手工覆盖 dist 部署时补列用)。

验证:verify:e2e 148/148(新增单双周 12 项 + 课程表导入 5 项)、verify:desktop 71/71(复跑 65/65)、
verify:web 20/20、typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
LoveChengke committed 2026-09-12 23:48:59 +08:00
1 parent 86430c5f4a
commit b8ef5d8dbb
34 files changed
+2351 -86

No files matched your search

+26 -1
View File
@@ -2,6 +2,8 @@ import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type ClassPlanImportResultDto,
type ClassPlanPreviewDto,
type ClassStatusDto,
type CourseDto,
type CreateClassRequest,
@@ -15,6 +17,7 @@ import {
type GradeDto,
type GradeStats,
type HomeworkDto,
type HomeworkSubmissionsDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
@@ -110,6 +113,11 @@ export const homeworkApi = {
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
/** 提交名单 / 未交名单(教师与班级设备可用) */
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
};
/* ------------------------------------------------------------------ 通知 */
@@ -202,7 +210,7 @@ export const teacherApi = {
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
};
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置 / ClassIsland 课程表) */
export const importApi = {
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
@@ -251,4 +259,21 @@ export const importApi = {
removeTimeLayout: (id: string): Promise<{ id: string }> =>
api.delete(`${API_PATHS.imports}/time-layout/${id}`),
/**
* ClassIsland 课程表(ClassPlan)解析预览(不写库)。
* 注意:请求体里的 JSON 字段名就叫 payload(与 ClassIsland 档案 JSON 同名)。
*/
previewClassPlan: (payload: {
classId: string;
mode?: 'replace' | 'merge';
payload: unknown;
}): Promise<ClassPlanPreviewDto> => api.post(`${API_PATHS.imports}/class-plan/preview`, payload),
/** 导入 ClassIsland 课程表(支持单双周:replace 覆盖 / merge 合并;失败会抛错且不改动原课表) */
importClassPlan: (payload: {
classId: string;
mode?: 'replace' | 'merge';
payload: unknown;
}): Promise<ClassPlanImportResultDto> => api.post(`${API_PATHS.imports}/class-plan`, payload),
};
@@ -0,0 +1,326 @@
<script setup lang="ts">
/**
* ClassIsland 课程表(ClassPlan)导入弹窗,支持单双周。
*
* 流程:粘贴 JSON / 选择 .json 文件(整份 ClassIsland 档案)
* → 解析预览(后端解析 TimeLayouts + ClassPlans + Subjects,单双周取 WeekCountDiv/WeekCountDivTotal)
* → 查看解析出的课程、缺失科目与校验结果 → 选择覆盖(replace) / 合并(merge) → 导入。
*
* 安全与一致性:
* - JSON 语法错误、结构无法识别、没有解析出任何课程时后端返回 errors(或 400),
* 导入按钮会被禁用/阻止,原课表不会被改成"导入一半"的脏状态;
* - 缺失的科目在导入时由后端自动补建(预览里提前列出,避免意外建课);
* - 权限与课表管理一致(管理员或本班班主任),科任老师看不到入口,后端亦会二次校验。
*/
import { computed, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { WEEK_PARITY_LABELS, type ClassPlanEntryDto, type ClassPlanPreviewDto } from '@classhelper/shared';
import { importApi } from '@/api';
const props = defineProps<{
modelValue: boolean;
classId: string;
className?: string;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
(event: 'imported'): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
/**
* 真实 ClassIsland 档案结构示例:Guid 字典 + PascalCase。
* 单双周 = 同一天两条 ClassPlan(WeekCountDiv 1/2,WeekCountDivTotal 2);
* 注意 TimeLayouts 必须在 ClassPlans 之前(Classes 按下标与"上课"时间点一一对应)。
*/
const CLASS_PLAN_SAMPLE = `{
"Name": "示例班级",
"TimeLayouts": {
"11111111-1111-4111-8111-111111111111": {
"Name": "周一作息",
"Layouts": [
{ "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0, "BreakName": "" },
{ "StartTime": "08:45:00", "EndTime": "08:55:00", "TimeType": 1, "BreakName": "课间" },
{ "StartTime": "08:55:00", "EndTime": "09:40:00", "TimeType": 0, "BreakName": "" }
]
}
},
"ClassPlans": {
"aaaaaaaa-0001-4000-8000-000000000001": {
"Name": "周一-单周",
"TimeLayoutId": "11111111-1111-4111-8111-111111111111",
"TimeRule": { "WeekDay": 1, "WeekCountDiv": 1, "WeekCountDivTotal": 2 },
"Classes": [
{ "SubjectId": "bbbbbbbb-0001-4000-8000-000000000001", "IsEnabled": true },
{ "SubjectId": "bbbbbbbb-0002-4000-8000-000000000002", "IsEnabled": true }
]
},
"aaaaaaaa-0002-4000-8000-000000000002": {
"Name": "周一-双周",
"TimeLayoutId": "11111111-1111-4111-8111-111111111111",
"TimeRule": { "WeekDay": 1, "WeekCountDiv": 2, "WeekCountDivTotal": 2 },
"Classes": [
{ "SubjectId": "bbbbbbbb-0001-4000-8000-000000000001", "IsEnabled": true },
{ "SubjectId": "bbbbbbbb-0003-4000-8000-000000000003", "IsEnabled": true }
]
}
},
"Subjects": {
"bbbbbbbb-0001-4000-8000-000000000001": { "Name": "语文", "TeacherName": "王芳" },
"bbbbbbbb-0002-4000-8000-000000000002": { "Name": "数学", "TeacherName": "李梅" },
"bbbbbbbb-0003-4000-8000-000000000003": { "Name": "英语", "TeacherName": "张伟" }
}
}`;
/** 输入框提示(用常量避免模板里的引号破坏 lint 规则) */
const TEXTAREA_PLACEHOLDER =
'粘贴 ClassIsland 导出的档案 JSON(含 TimeLayouts / ClassPlans / Subjects),或点击「选择 .json 文件」';
const jsonText = ref('');
const mode = ref<'replace' | 'merge'>('replace');
const preview = ref<ClassPlanPreviewDto | null>(null);
const parsing = ref(false);
const importing = ref(false);
const fileInputRef = ref<HTMLInputElement>();
watch(visible, (value) => {
if (value) {
jsonText.value = '';
preview.value = null;
mode.value = 'replace';
}
});
function pickFile(): void {
fileInputRef.value?.click();
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!/\.json$/i.test(file.name)) {
ElMessage.warning('请选择 ClassIsland 导出的 .json 文件');
input.value = '';
return;
}
jsonText.value = await file.text();
preview.value = null;
ElMessage.success(`已读取 ${file.name}(${jsonText.value.length} 字符),请点击「解析预览」`);
}
/** JSON 语法错误由后端统一报错,这里保留原文让后端给出精确原因 */
function parseJsonOrText(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
/** 解析出的课程数(单双周会各算一节,便于核对) */
const parityCount = computed(() => {
const counts = { ODD: 0, EVEN: 0, ALL: 0 };
for (const item of preview.value?.entries ?? []) counts[item.weekParity] += 1;
const parts: string[] = [];
if (counts.ODD > 0) parts.push(`单周 ${counts.ODD} 节`);
if (counts.EVEN > 0) parts.push(`双周 ${counts.EVEN} 节`);
if (counts.ALL > 0) parts.push(`每周 ${counts.ALL} 节`);
return parts.join(' / ');
});
/** 解析预览:把 JSON 文本或对象交给后端解析(不写库) */
async function doPreview(): Promise<void> {
if (!jsonText.value.trim()) {
ElMessage.warning('请先粘贴 JSON 或选择 .json 文件');
return;
}
if (!props.classId) {
ElMessage.warning('请先在页面上选择班级');
return;
}
parsing.value = true;
try {
preview.value = await importApi.previewClassPlan({
classId: props.classId,
mode: mode.value,
payload: parseJsonOrText(jsonText.value),
});
if (preview.value.errors.length > 0) {
ElMessage.warning(`解析发现 ${preview.value.errors.length} 个问题,请修正后再导入`);
} else {
ElMessage.success(
`解析成功:共 ${preview.value.entries.length} 节课${parityCount.value ? `(${parityCount.value})` : ''}`,
);
}
} catch {
preview.value = null;
} finally {
parsing.value = false;
}
}
async function doImport(): Promise<void> {
if (!preview.value || preview.value.entries.length === 0) {
ElMessage.warning('请先解析预览并确认课程');
return;
}
if (preview.value.errors.length > 0) {
ElMessage.warning('存在解析错误,已阻止导入(原课表不会被修改)');
return;
}
importing.value = true;
try {
const result = await importApi.importClassPlan({
classId: props.classId,
mode: mode.value,
payload: parseJsonOrText(jsonText.value),
});
ElMessage.success(`${result.created} 新增 / ${result.updated} 更新`);
emit('imported');
visible.value = false;
} finally {
importing.value = false;
}
}
function fillSample(): void {
jsonText.value = CLASS_PLAN_SAMPLE;
preview.value = null;
}
function parityLabel(item: ClassPlanEntryDto): string {
return WEEK_PARITY_LABELS[item.weekParity] ?? item.weekParity;
}
</script>
<template>
<el-dialog v-model="visible" title="导入 ClassIsland 课程表(支持单双周)" width="860px" top="5vh">
<el-alert type="info" :closable="false" show-icon class="mb-12">
<template #title>
支持 ClassIsland 档案 JSON(含 TimeLayouts / ClassPlans / Subjects 的 Guid 字典)。单双周由 ClassPlan
的 WeekCountDiv / WeekCountDivTotal 决定(两条 ClassPlan = 单周 +
双周各一节);课间(TimeType=1)会自动跳过。解析失败不会修改原有课表。
</template>
</el-alert>
<el-form label-width="110px" size="small">
<el-form-item label="目标班级">
<el-input :model-value="className || classId" disabled style="width: 260px" />
</el-form-item>
<el-form-item label="导入方式">
<el-radio-group v-model="mode">
<el-radio value="replace">覆盖:清空该班现有课表后写入</el-radio>
<el-radio value="merge">合并:保留现有课表,同星期+节次+单双周则更新</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div class="toolbar mb-8">
<el-button size="small" :loading="parsing" type="primary" @click="doPreview">解析预览</el-button>
<el-button size="small" @click="pickFile">选择 .json 文件</el-button>
<el-button size="small" @click="fillSample">填入示例</el-button>
<input ref="fileInputRef" type="file" accept=".json" class="hidden-input" @change="onFileChange" />
</div>
<el-input v-model="jsonText" type="textarea" :rows="7" :placeholder="TEXTAREA_PLACEHOLDER" />
<template v-if="preview">
<div class="preview-meta">
<span>解析课程:{{ preview.entries.length }} 节</span>
<span v-if="parityCount">单双周分布:{{ parityCount }}</span>
<span>识别科目:{{ preview.subjects.length }} 个</span>
<span v-if="preview.layoutNames.length">时间表:{{ preview.layoutNames.join('、') }}</span>
</div>
<el-alert
v-for="(item, index) in preview.errors"
:key="`e-${index}`"
type="error"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-alert
v-for="(item, index) in preview.warnings"
:key="`w-${index}`"
type="warning"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-alert
v-if="preview.missingSubjects.length"
type="info"
:closable="false"
show-icon
class="mb-8"
:title="`导入时会自动补建这些课程:${preview.missingSubjects.join('、')}`"
/>
<el-table
v-if="preview.entries.length"
:data="preview.entries"
size="small"
max-height="260"
class="mt-8"
>
<el-table-column label="星期" width="80">
<template #default="{ row }">{{ row.weekdayLabel ?? `周${row.dayOfWeek}` }}</template>
</el-table-column>
<el-table-column label="时间" width="130">
<template #default="{ row }">{{ row.startTime }}-{{ row.endTime }}</template>
</el-table-column>
<el-table-column prop="subject" label="科目" min-width="110" />
<el-table-column label="单双周" width="90">
<template #default="{ row }">{{ parityLabel(row) }}</template>
</el-table-column>
<el-table-column prop="planName" label="来源" min-width="120" />
</el-table>
</template>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button
type="primary"
:disabled="!preview || preview.entries.length === 0 || preview.errors.length > 0"
:loading="importing"
@click="doImport"
>
开始导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.hidden-input {
display: none;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
color: #606266;
font-size: 13px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
.mt-8 {
margin-top: 8px;
}
</style>
+146 -1
View File
@@ -8,6 +8,7 @@ import {
type ClassDto,
type CourseDto,
type HomeworkDto,
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import { classApi, courseApi, homeworkApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
@@ -34,6 +35,65 @@ const completedMap = computed(() => {
return { total, done, rate: total === 0 ? 0 : Math.round((done / total) * 100) };
});
/* ------------------------------------------------------------ 未交名单(勾选谁没交) */
const submissionsVisible = ref(false);
const submissionsLoading = ref(false);
const submissionsSaving = ref(false);
const submissionStudents = ref<HomeworkSubmissionsDto['students']>([]);
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
const notSubmittedIds = ref<string[]>([]);
const notSubmittedNames = computed(() =>
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
);
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<void> {
submissionsLoading.value = true;
try {
const result = await homeworkApi.submissions(homeworkId);
submissionStudents.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
} catch {
submissionStudents.value = [];
notSubmittedIds.value = [];
} finally {
submissionsLoading.value = false;
}
}
async function openSubmissions(): Promise<void> {
if (!current.value) return;
if (submissionStudents.value.length === 0) await loadSubmissions(current.value.id);
submissionsVisible.value = true;
}
async function saveSubmissions(): Promise<void> {
if (!current.value) return;
submissionsSaving.value = true;
try {
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
submissionStudents.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
if (current.value) current.value.completedCount = result.completedCount;
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
submissionsVisible.value = false;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存未交名单失败');
} finally {
submissionsSaving.value = false;
}
}
function markAllSubmitted(): void {
notSubmittedIds.value = [];
}
function markAllNotSubmitted(): void {
notSubmittedIds.value = submissionStudents.value.map((item) => item.userId);
}
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
@@ -136,6 +196,7 @@ async function openDetail(row: HomeworkDto): Promise<void> {
current.value = await homeworkApi.detail(row.id);
const students = await classApi.students(row.classId);
classStudents.value = students.map((item) => ({ id: item.id, name: item.name }));
void loadSubmissions(row.id);
detailVisible.value = true;
}
@@ -257,6 +318,27 @@ onUnmounted(() => {
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item>
<el-descriptions-item label="未交名单" :span="2">
<div v-loading="submissionsLoading" class="submission-summary">
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
<template v-else-if="notSubmittedNames.length === 0">
<el-tag type="success" size="small" effect="light">全部已交</el-tag>
</template>
<template v-else>
<el-tag
v-for="student in notSubmittedNames"
:key="student.userId"
type="warning"
size="small"
effect="light"
class="submission-tag"
>
{{ student.name }}
</el-tag>
</template>
<el-button size="small" type="primary" plain @click="openSubmissions">勾选未交</el-button>
</div>
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
@@ -277,6 +359,35 @@ onUnmounted(() => {
/>
</template>
</el-drawer>
<!-- 未交名单:勾选谁没交,其余学生自动标记为已交 -->
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
<p class="text-muted" style="margin-top: 0">
勾选
<strong>没交作业</strong>
的同学,保存后其余同学会自动标记为已完成。
</p>
<div class="submission-toolbar">
<el-button size="small" @click="markAllSubmitted">全部已交</el-button>
<el-button size="small" @click="markAllNotSubmitted">全部未交</el-button>
<span class="text-muted">未交 {{ notSubmittedIds.length }} / {{ submissionStudents.length }} 人</span>
</div>
<div v-loading="submissionsLoading" class="submission-list">
<el-empty
v-if="submissionStudents.length === 0 && !submissionsLoading"
description="该班还没有学生账号"
/>
<el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissionStudents" :key="student.userId" :value="student.userId">
{{ student.name }}({{ student.username }})
</el-checkbox>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="submissionsVisible = false">取消</el-button>
<el-button type="primary" :loading="submissionsSaving" @click="saveSubmissions">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -289,8 +400,42 @@ onUnmounted(() => {
white-space: pre-wrap;
line-height: 1.7;
background: #fafafa;
border-radius: 8px;
border-radius: 12px;
padding: 12px;
margin: 0;
}
/* ---------------------------------------------------------------- 未交名单 */
.submission-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.submission-tag {
margin: 0;
}
.submission-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #eef0f5;
border-radius: 12px;
padding: 12px;
}
.submission-list :deep(.el-checkbox-group) {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
+65 -1
View File
@@ -5,15 +5,20 @@ import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
WEEK_PARITY_LABELS,
WEEK_PARITY_VALUES,
buildWeekOptions,
formatWeekRange,
weekParityOf,
type ClassDto,
type CourseDto,
type CreateScheduleRequest,
type ScheduleDto,
type ScheduleWeekView,
type WeekParity,
} from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import ClassPlanImportDialog from '@/components/ClassPlanImportDialog.vue';
import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
@@ -41,6 +46,12 @@ const rawSchedules = ref<ScheduleDto[]>([]);
const query = reactive({ classId: '', week: 1 });
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 当前周次的单双周(第 1 周记为单周),用于工具栏提示与课表校验 */
const currentWeekParity = computed(() => weekParityOf(query.week));
const currentWeekParityLabel = computed(
() => `第 ${query.week} 周 · ${WEEK_PARITY_LABELS[currentWeekParity.value]}`,
);
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
@@ -112,6 +123,8 @@ const form = reactive({
location: '',
weekStart: 1,
weekEnd: 20,
/** 单双周:ALL 每周(默认)/ ODD 单周 / EVEN 双周 */
weekParity: 'ALL' as WeekParity,
});
const rules: FormRules = {
@@ -133,6 +146,7 @@ function openCreate(): void {
form.location = '';
form.weekStart = 1;
form.weekEnd = weekOptions.value.at(-1) ?? 20;
form.weekParity = 'ALL';
formVisible.value = true;
}
@@ -145,6 +159,7 @@ function openEdit(item: ScheduleDto): void {
form.location = item.location ?? '';
form.weekStart = item.weekStart;
form.weekEnd = item.weekEnd;
form.weekParity = item.weekParity ?? 'ALL';
formVisible.value = true;
}
@@ -165,6 +180,7 @@ async function submitForm(): Promise<void> {
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
weekParity: form.weekParity,
};
if (editingId.value) {
@@ -207,6 +223,24 @@ function openTimeLayoutImport(): void {
timeLayoutVisible.value = true;
}
/* ------------------------------------------------------------ ClassIsland 课程表导入(支持单双周) */
const classPlanVisible = ref(false);
function openClassPlanImport(): void {
if (!query.classId) {
ElMessage.warning('请先选择班级');
return;
}
classPlanVisible.value = true;
}
/** 导入成功后刷新周视图与列表(grid + list 都在 loadSchedules 里) */
async function onClassPlanImported(): Promise<void> {
await loadCourses();
await loadSchedules();
}
onMounted(async () => {
await loadBase();
await loadSchedules();
@@ -237,6 +271,7 @@ onUnmounted(() => {
<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-tag type="info" effect="plain">{{ currentWeekParityLabel }}</el-tag>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
新增课表
@@ -244,6 +279,15 @@ onUnmounted(() => {
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
导入时间配置
</el-button>
<el-button
v-if="canManageSchedule"
type="warning"
plain
:icon="'Upload'"
@click="openClassPlanImport"
>
导入 ClassIsland 课程表
</el-button>
</div>
</div>
@@ -268,7 +312,12 @@ onUnmounted(() => {
<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-course">
{{ item.course?.name ?? '-' }}
<el-tag v-if="item.weekParity !== 'ALL'" size="small" effect="plain">
{{ WEEK_PARITY_LABELS[item.weekParity] }}
</el-tag>
</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<div>
@@ -323,6 +372,13 @@ onUnmounted(() => {
<span style="margin: 0 8px">至</span>
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
</el-form-item>
<el-form-item label="单双周" prop="weekParity">
<el-radio-group v-model="form.weekParity">
<el-radio v-for="parity in WEEK_PARITY_VALUES" :key="parity" :value="parity">
{{ WEEK_PARITY_LABELS[parity] }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
@@ -336,5 +392,13 @@ onUnmounted(() => {
:class-id="query.classId"
:class-name="classes.find((item) => item.id === query.classId)?.name"
/>
<!-- ClassIsland 课程表导入(支持单双周;导入成功后刷新周视图与列表) -->
<ClassPlanImportDialog
v-model="classPlanVisible"
:class-id="query.classId"
:class-name="classes.find((item) => item.id === query.classId)?.name"
@imported="onClassPlanImported"
/>
</div>
</template>