Files
ClassHelper/packages/web-admin/src/components/ClassPlanImportDialog.vue
T
LoveChengke 11008928a1 fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
  (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 已重新打包并部署到本机安装目录。
2026-09-13 08:39:11 +08:00

332 lines
11 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">
/**
* 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>