一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
(a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
(b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
"用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。
二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
(工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。
三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
(不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。
四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。
验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
332 lines
11 KiB
Vue
332 lines
11 KiB
Vue
<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="100">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.weekParity !== 'ALL'" size="small" effect="plain">
|
||
{{ parityLabel(row) }}
|
||
</el-tag>
|
||
<span v-else class="text-muted">{{ parityLabel(row) }}</span>
|
||
</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>
|