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

@@ -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>