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 已重新打包并部署到本机安装目录。
This commit is contained in:
LoveChengke committed 2026-09-13 08:39:11 +08:00
1 parent b8ef5d8dbb
commit 11008928a1
34 files changed
+1421 -340

No files matched your search

@@ -9,6 +9,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
mode: 'board',
showTime: false,
fontSize: 15,
todayOnly: true,
};
interface PersistedConfig {
@@ -42,6 +43,7 @@ function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): Homewor
return {
mode,
showTime: input?.showTime === true,
todayOnly: input?.todayOnly !== false,
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
};
}
+47 -9
View File
@@ -126,6 +126,8 @@ class IslandController {
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
private backgroundMaterial: string = 'none';
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
private blurCollapseEnabled = true;
private interactive = false;
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
@@ -137,6 +139,8 @@ class IslandController {
private lastHitSyncAt = 0;
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
private previewId: string | null = null;
/** 上课时段里"用户主动点开"的通知 id(syncWindow 的上课守卫对它放行) */
private explicitShowId: string | null = null;
/** 预览示例岛的自动收尾定时器 */
private previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -225,6 +229,8 @@ class IslandController {
* 只在激活满 500ms 后才把 blur 当作"用户点击了别处"。
*/
win.on('blur', () => {
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
if (!this.blurCollapseEnabled) return;
if (this.state.mode !== 'expanded' || this.state.inClass) return;
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
if (this.previewId && this.state.active?.id === this.previewId) return;
@@ -388,6 +394,7 @@ class IslandController {
if (payload.inClass) {
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
this.explicitShowId = null;
if (this.state.mode !== 'hidden') {
// 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影)
this.setState({ mode: 'hidden', reason: null });
@@ -434,21 +441,38 @@ class IslandController {
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
const showing = this.state.active;
if (this.state.inClass && !this.isOpenable(showing)) {
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
// 而不是反复打开上一条 —— 否则表现为"点不开新作业"(用户反馈的联合通知场景)。
// 岛完全隐藏时仍严格遵循上课不打扰。
const islandVisible = this.state.mode !== 'hidden';
if (this.state.inClass && !this.isOpenable(showing) && !islandVisible) {
logger.info(
`灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`,
);
break;
}
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
if (!this.state.active) {
const pending = this.state.queued[this.state.queued.length - 1];
const newestQueued = this.state.queued[this.state.queued.length - 1];
const shouldOpenNewest = Boolean(
newestQueued &&
(!showing || String(newestQueued.createdAt ?? '') > String(showing.createdAt ?? '')),
);
if (!this.state.active || shouldOpenNewest) {
const pending = newestQueued;
if (pending) {
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
// 当前展示的那条回队列,换成最新的一条
if (this.state.active && this.state.active.id !== pending.id) {
this.state.queued = this.state.queued.filter(
(item) => item.id !== pending.id && item.id !== this.state.active?.id,
);
this.state.queued.push(this.state.active);
} else {
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
}
this.state.active = pending;
logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`);
logger.info(
`灵动岛:收起态点击,打开最新通知 ${pending.id}(队列 ${this.state.queued.length} 条)`,
);
}
}
const active = this.state.active;
@@ -458,6 +482,10 @@ class IslandController {
`灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`,
);
}
// 上课时段里"用户主动点开"的消息要能真的留在屏幕上:
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
// 表现为"点不开新作业"——用户反馈的联合通知场景)。
this.explicitShowId = this.state.inClass ? active.id : null;
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
this.isImmediate(active)
@@ -511,6 +539,7 @@ class IslandController {
hide(): void {
this.clearTimers();
this.explicitShowId = null;
if (!this.win || this.win.isDestroyed()) return;
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
@@ -529,6 +558,7 @@ class IslandController {
hideImmediately(): void {
this.clearTimers();
this.cancelBoundsAnimation();
this.explicitShowId = null;
this.fadeToken += 1;
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
if (changed) {
@@ -699,7 +729,10 @@ class IslandController {
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
const openableShowing = this.state.active !== null && this.isOpenable(this.state.active);
if (this.state.inClass && !openableShowing) {
// 用户在课中主动点开的那条同样允许显示(否则"点开了又被立刻隐藏")
const explicitShowing =
this.state.active !== null && this.state.active.id === this.explicitShowId && !this.previewId;
if (this.state.inClass && !openableShowing && !explicitShowing) {
this.hideImmediately();
return;
}
@@ -978,6 +1011,11 @@ class IslandController {
}
/** 当前是否接收鼠标(供冒烟验证) */
/** 诊断/冒烟用:开关"失焦自动收起" */
setBlurCollapseEnabled(enabled: boolean): void {
this.blurCollapseEnabled = enabled;
}
getInteractive(): boolean {
return this.interactive;
}
+246
View File
@@ -457,6 +457,61 @@ async function dumpIslandDom(_label: string): Promise<string> {
}
}
/**
* 冒烟用:用教师账号发一条普通通知,返回通知 id(失败返回空串)。
* 用于"未读红点位置"这类需要真实未读数据的断言。
*/
async function createSmokeNotification(): Promise<string> {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
try {
const login = (await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json())) as { data?: { token?: string } };
const token = login?.data?.token;
if (!token) return '';
const classes = (await fetch(`${base}/classes`, {
headers: { authorization: `Bearer ${token}` },
}).then((response) => response.json())) as { data?: Array<{ id?: string }> };
const classId = classes?.data?.[0]?.id ?? '';
if (!classId) return '';
const created = (await fetch(`${base}/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
title: '红点位置校验',
content: '自动化验证用通知(断言未读红点位置后会删除)。',
priority: 'NORMAL',
}),
}).then((response) => response.json())) as { data?: { id?: string } };
return created?.data?.id ?? '';
} catch {
return '';
}
}
/** 冒烟用:删除上面造的通知(失败忽略) */
async function deleteSmokeNotification(id: string): Promise<void> {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
try {
const login = (await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json())) as { data?: { token?: string } };
const token = login?.data?.token;
if (!token) return;
await fetch(`${base}/notifications/${id}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${token}` },
});
} catch {
// 清理失败不影响冒烟结论
}
}
function makeNotification(
id: string,
priority: IslandNotification['priority'],
@@ -492,6 +547,9 @@ async function runIslandChecks(
if (!island.isReady()) return;
const islandWindow = island.getWindow();
// 默认关掉"失焦自动收起":冒烟是自动化环境,系统/其它窗口抢焦点会随时触发 blur,
// 把展开态收回会让断言随机失败。专门验证该行为的用例(4.5)会临时打开它。
island.setBlurCollapseEnabled(false);
// 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
@@ -837,6 +895,7 @@ async function runIslandChecks(
await sleep(700);
const focusable = islandWindow?.isFocusable() ?? false;
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为
islandWindow?.emit('blur');
await sleep(700);
const afterBlur = island.getState();
@@ -845,6 +904,8 @@ async function runIslandChecks(
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
);
// 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言
island.setBlurCollapseEnabled(false);
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
@@ -1149,6 +1210,9 @@ async function runIslandChecks(
await sleep(500);
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
// 先把光标注入到屏幕左上角(等价于指针离开岛体),否则轮询会立刻把命中又打开。
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
island.setInteractive(false);
await sleep(60);
const forcedThrough = island.getInteractive();
@@ -1316,6 +1380,66 @@ async function runIslandChecks(
`mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`,
);
// 4.12) 用户复现路径:紧急通知 + 新作业(联合通知)时,收起后点击必须能打开**新作业**
// 之前 active 仍是紧急那条,点击只会反复打开紧急,表现为"点不开新作业"。
await drainForCollapseCheck();
await sleep(300);
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(300);
island.pushNotification(makeNotification('smoke-mixed-urgent', 'URGENT', '上课期间的紧急通知'), {
inClass: true,
});
await sleep(700);
const mixedUrgent = island.getState();
// 收起(学生点空白处)
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(600);
const mixedCollapsed = island.getState();
// 又来了新作业(上课时段,属普通通知 → 进队列,但胶囊已把这个类型汇总出来了)
island.pushNotification(
{
...makeNotification('smoke-mixed-homework', 'NORMAL', '今天的新作业'),
kind: 'homework',
},
{ inClass: true },
);
await sleep(600);
const mixedPillDom = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.pill .pill-title')?.textContent?.trim() ?? ''`,
);
// 点击胶囊:必须打开**新作业**这条
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(750);
const mixedOpened = island.getState();
record(
'紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)',
mixedUrgent.mode === 'expanded' &&
mixedCollapsed.mode === 'pill' &&
(mixedPillDom ?? '').includes('作业') &&
mixedOpened.mode === 'expanded' &&
mixedOpened.active?.id === 'smoke-mixed-homework',
`紧急态=${mixedUrgent.mode} 收起=${mixedCollapsed.mode} 胶囊标题="${mixedPillDom ?? '-'}" ` +
`点击后=${mixedOpened.mode} active=${mixedOpened.active?.id ?? '-'}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(300);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => {
@@ -1389,6 +1513,74 @@ async function runIslandChecks(
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
const appearanceBefore = island.getAppearance();
// 8.1) 设置页真实链路(渲染进程 → IPC → 主进程):用户反馈"设置不生效",
// 因此必须走设置页用的 `window.desktop.islandSetAppearance`,而不是直接调主进程 API。
const rendererApplied = await mainWin?.webContents
.executeJavaScript(
`(() => {
if (!window.desktop?.islandSetAppearance) return false;
window.desktop.islandSetAppearance({
height: 62,
width: 320,
radius: 28,
fontSize: 16,
opacity: 0.9,
accent: '#ff7043',
});
return true;
})()`,
)
.catch(() => false);
await sleep(450);
const rendererAppearance = island.getAppearance();
const rendererCssVars = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const style = getComputedStyle(document.documentElement);
return {
h: style.getPropertyValue('--island-h').trim(),
w: style.getPropertyValue('--island-w').trim(),
font: style.getPropertyValue('--island-font').trim(),
accent: style.getPropertyValue('--island-accent').trim(),
};
})()`,
)
.catch(() => null);
record(
'设置页链路:渲染进程 islandSetAppearance 实时生效(尺寸/字号/主题色)',
rendererApplied === true &&
rendererAppearance.height === 62 &&
rendererAppearance.width === 320 &&
rendererAppearance.fontSize === 16 &&
rendererAppearance.accent.toLowerCase() === '#ff7043' &&
rendererCssVars?.h === '62px' &&
rendererCssVars?.w === '320px' &&
rendererCssVars?.font === '16px' &&
rendererCssVars?.accent.toLowerCase() === '#ff7043',
`渲染进程调用=${rendererApplied} 主进程外观=${rendererAppearance.width}x${rendererAppearance.height} ` +
`字号=${rendererAppearance.fontSize} 主题色=${rendererAppearance.accent} ` +
`CSS 变量 h=${rendererCssVars?.h} w=${rendererCssVars?.w} font=${rendererCssVars?.font} accent=${rendererCssVars?.accent}`,
);
// 岛**可见**时改尺寸也要立刻生效(用户在设置页调滑块时岛正显示着)
island.pushNotification(makeNotification('smoke-appearance-live', 'NORMAL', '实时预览校验'), {
inClass: false,
});
await sleep(500);
await mainWin?.webContents
.executeJavaScript(
`(() => {
window.desktop?.islandSetAppearance({ height: 66, width: 336, fontSize: 17 });
return true;
})()`,
)
.catch(() => undefined);
await sleep(450);
const liveBounds = (await readIslandGeometry())?.island ?? null;
record(
'设置页链路:岛显示中改尺寸也立刻生效(实时预览)',
liveBounds?.height === 66 && Math.abs((liveBounds?.width ?? 0) - 336) <= 1,
`岛=${liveBounds?.width?.toFixed(0) ?? '-'}x${liveBounds?.height?.toFixed(0) ?? '-'}(期望 336x66)`,
);
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
island.handleAction({ action: 'dismiss' });
await sleep(300);
@@ -2281,6 +2473,60 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{
ok: boolean;
skipped?: string;
reason?: string;
dx?: number;
dy?: number;
text?: string;
} | null> =>
await win.webContents
.executeJavaScript(
`(() => {
const item = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').includes('通知'),
);
if (!item) return { ok: false, reason: '找不到通知菜单项' };
const badge = item.querySelector('.menu-badge');
const icon = item.querySelector('.menu-icon-slot .el-icon');
if (!badge) return { ok: true, skipped: '当前没有未读消息' };
if (!icon) return { ok: false, reason: '通知图标没有 .menu-icon-slot 定位父级' };
const b = badge.getBoundingClientRect();
const i = icon.getBoundingClientRect();
return {
ok: true,
dx: Math.round(b.left + b.width / 2 - (i.left + i.width / 2)),
dy: Math.round(b.top + b.height / 2 - (i.top + i.height / 2)),
text: (badge.textContent ?? '').trim(),
};
})()`,
)
.catch(() => null);
let badgeCheck = await readBadgeGeometry();
let injectedId = '';
if (badgeCheck?.skipped) {
// 没有未读消息时红点不会渲染:造一条未读通知(教师账号),等实时推送刷新后再断言位置
const created = await createSmokeNotification();
if (created) {
injectedId = created;
await sleep(1400);
badgeCheck = await readBadgeGeometry();
}
}
record(
'未读红点位置正确(挂在"通知"图标右上方)',
badgeCheck?.ok === true &&
(badgeCheck?.skipped !== undefined || ((badgeCheck?.dx ?? 0) > 0 && (badgeCheck?.dy ?? 0) < 0)),
badgeCheck?.skipped
? `${badgeCheck.skipped}(未能构造未读消息,已跳过位置断言)`
: `红点=${badgeCheck?.text} 相对图标中心 dx=${badgeCheck?.dx} dy=${badgeCheck?.dy}(要求 dx>0 且 dy<0)` +
`${badgeCheck?.reason ? ` 原因=${badgeCheck.reason}` : ''}`,
);
if (injectedId) await deleteSmokeNotification(injectedId);
const cleanup = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
@@ -67,5 +67,6 @@ export const gradeApi = {
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
api.get('/dashboard/term', classId ? { classId } : undefined),
};
@@ -106,13 +106,17 @@ onUnmounted(() => {
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
class="menu-badge"
/>
</el-menu-item>
</el-menu>
<div class="aside-footer">
@@ -217,8 +221,40 @@ onUnmounted(() => {
--el-menu-active-color: var(--ch-text);
}
/* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
.menu-icon-slot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/*
* 未读红点:定位到图标"右上角外侧"。
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
*/
.menu-badge {
margin-left: 10px;
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
margin: 0;
pointer-events: none;
}
.menu-badge :deep(.el-badge__content) {
position: absolute;
top: -7px;
right: -11px;
transform: none;
height: 16px;
min-width: 16px;
padding: 0 4px;
line-height: 16px;
font-size: 11px;
border: none;
}
.aside-footer {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
@@ -13,7 +13,12 @@ const auth = useAuthStore();
const realtime = useRealtimeStore();
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
const DEFAULT_BOARD = { mode: 'board' as 'board' | 'list', showTime: false, fontSize: 15 };
const DEFAULT_BOARD = {
mode: 'board' as 'board' | 'list',
showTime: false,
fontSize: 15,
todayOnly: true,
};
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
@@ -27,20 +32,51 @@ const submitting = ref(false);
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
const boardShowTime = ref(DEFAULT_BOARD.showTime);
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
const todayOnly = ref(DEFAULT_BOARD.todayOnly);
const savingBoard = ref(false);
/** 全屏放大的科目(null = 未打开) */
const fullscreenCourse = ref<string | null>(null);
const fullscreenBoard = ref(false);
/* ------------------------------------------------------------ 当天的判定 */
/** 本地"今天"的日期串(YYYY-MM-DD) */
function localDayKey(value: string | Date): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** 每分钟刷新一次"现在",用于标题栏时钟与"今天"的判定 */
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | null = null;
const todayKey = computed(() => localDayKey(now.value));
const todayText = computed(
() =>
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.value.getDay()]} ` +
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
);
/** 按"今天"与完成状态过滤 */
const filtered = computed(() => {
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
return homeworks.value;
let list = homeworks.value;
if (todayOnly.value) list = list.filter((item) => localDayKey(item.createdAt) === todayKey.value);
if (filter.value === 'pending') return list.filter((item) => item.completed !== true);
if (filter.value === 'done') return list.filter((item) => item.completed === true);
return list;
});
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
const todayCount = computed(
() => homeworks.value.filter((item) => localDayKey(item.createdAt) === todayKey.value).length,
);
const pendingCount = computed(() => filtered.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => filtered.value.filter((item) => item.completed === true).length);
/**
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
@@ -60,6 +96,7 @@ const COURSE_ORDER = [
'音乐',
'美术',
'信息技术',
'通用技术',
];
interface BoardColumn {
@@ -98,6 +135,39 @@ const fullscreenItems = computed(() =>
: [],
);
/* ------------------------------------------------------------ 看板自适应缩放
*
* 需求:作业必须**全部完整显示**(标题 + 作业要求),且**不需要上下滚动**。
* 做法:先把卡片按视口宽度铺成网格,再按"内容高度 / 可用高度"算一个缩放系数,
* 用 transform: scale() 整体缩到刚好铺满(字号仍由用户滑块决定,缩放只是兜底适配)。
*/
const boardHostRef = ref<HTMLElement | null>(null);
const boardInnerRef = ref<HTMLElement | null>(null);
const boardScale = ref(1);
const fullscreenHostRef = ref<HTMLElement | null>(null);
const fullscreenInnerRef = ref<HTMLElement | null>(null);
const fullscreenScale = ref(1);
function computeScale(host: HTMLElement | null, inner: HTMLElement | null): number {
if (!host || !inner) return 1;
const available = host.clientHeight;
// 先按 1 倍量一次内容高度(transform 不影响 offsetHeight)
const content = inner.offsetHeight;
if (available <= 0 || content <= 0) return 1;
const scale = Math.min(1, available / content);
return Math.max(0.45, Math.round(scale * 1000) / 1000);
}
function fitBoards(): void {
void nextTick(() => {
boardScale.value = computeScale(boardHostRef.value, boardInnerRef.value);
fullscreenScale.value = computeScale(fullscreenHostRef.value, fullscreenInnerRef.value);
});
}
/* ------------------------------------------------------------ 数据加载 */
async function loadHomework(): Promise<void> {
loading.value = true;
try {
@@ -115,6 +185,7 @@ async function loadHomework(): Promise<void> {
if (current.value) {
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
}
fitBoards();
} finally {
loading.value = false;
}
@@ -127,6 +198,7 @@ async function loadBoardSettings(): Promise<void> {
if (!saved) return;
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
if (typeof saved.todayOnly === 'boolean') todayOnly.value = saved.todayOnly;
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
}
@@ -141,8 +213,10 @@ async function saveBoardSettings(): Promise<void> {
mode: viewMode.value,
showTime: boardShowTime.value,
fontSize: boardFontSize.value,
todayOnly: todayOnly.value,
},
});
fitBoards();
} finally {
savingBoard.value = false;
}
@@ -155,12 +229,14 @@ function openDetail(item: HomeworkDto): void {
function openCourseFullscreen(column: BoardColumn): void {
fullscreenCourse.value = column.course;
fitBoards();
}
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
function openBoardFullscreen(): void {
fullscreenCourse.value = null;
fullscreenBoard.value = true;
fitBoards();
}
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
@@ -266,15 +342,28 @@ function onRecovered(): void {
void loadHomework();
}
let resizeObserver: ResizeObserver | null = null;
onMounted(async () => {
await loadBoardSettings();
await loadHomework();
clockTimer = setInterval(() => {
now.value = new Date();
}, 30_000);
// 视口变化后重新适配(保证"全部显示在屏幕上"始终成立)
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => fitBoards());
if (boardHostRef.value) resizeObserver.observe(boardHostRef.value);
}
fitBoards();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer);
resizeObserver?.disconnect();
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
@@ -283,16 +372,22 @@ onUnmounted(() => {
</script>
<template>
<div class="page">
<div class="page homework-page">
<div class="page-header">
<div>
<h2 class="page-title">我的作业</h2>
<p class="page-subtitle">
共 {{ homeworks.length }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
今天 {{ todayCount }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-switch
v-model="todayOnly"
active-text="只看今天"
inactive-text="全部日期"
@change="saveBoardSettings"
/>
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="pending">未完成</el-radio-button>
@@ -322,50 +417,57 @@ onUnmounted(() => {
:min="11"
:max="28"
:step="1"
style="width: 200px"
style="width: 180px"
@change="saveBoardSettings"
/>
<span class="text-muted">{{ boardFontSize }}px</span>
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
<span class="text-muted">自适应缩放 {{ Math.round(boardScale * 100) }}%</span>
<span v-if="savingBoard" class="text-muted">保存中…</span>
</div>
</el-card>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
<el-card v-loading="loading" shadow="never" class="homework-body">
<el-empty v-if="filtered.length === 0" description="今天没有作业" />
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
<div v-else-if="viewMode === 'board'" class="board" :style="boardStyle">
<section
v-for="column in boardColumns"
:key="column.course"
class="board-card"
:class="{ 'has-pending': column.pending > 0 }"
@click="openCourseFullscreen(column)"
>
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click.stop="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-title">{{ item.title }}</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
<footer class="board-card-foot">
<span class="text-muted">共 {{ column.items.length }} 条</span>
<span class="board-zoom">点击放大</span>
</footer>
</section>
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
<div ref="boardInnerRef" class="board" :style="{ ...boardStyle, '--board-scale': boardScale }">
<section
v-for="column in boardColumns"
:key="column.course"
class="board-card"
:class="{ 'has-pending': column.pending > 0 }"
@click="openCourseFullscreen(column)"
>
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click.stop="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<!-- 需求:作业要完整显示(标题 + 作业要求),不能只显示标题 -->
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
<footer class="board-card-foot">
<span class="text-muted">共 {{ column.items.length }} 条</span>
<span class="board-zoom">点击放大</span>
</footer>
</section>
</div>
</div>
<!-- 列表模式(默认表格视图) -->
@@ -377,7 +479,8 @@ onUnmounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="作业标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="title" label="作业标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
@@ -410,45 +513,76 @@ onUnmounted(() => {
:title="`${fullscreenCourse ?? ''} · 作业`"
class="board-fullscreen"
>
<ol class="board-list board-list-lg" :style="fullscreenStyle">
<li
v-for="(item, index) in fullscreenItems"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">{{ fullscreenCourse ?? '' }} · 作业</span>
<!-- 需求:当天时间显示在标题栏正中间(受"显示时间"开关控制) -->
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="fullscreenHostRef" class="board-host board-host-lg">
<ol
ref="fullscreenInnerRef"
class="board-list board-list-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-title">{{ item.title }}</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
<el-empty v-if="fullscreenItems.length === 0" description="该科目暂无作业" />
<li
v-for="(item, index) in fullscreenItems"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
<span v-if="item.completed" class="board-done-tag">已完成</span>
</li>
</ol>
</div>
<el-empty v-if="fullscreenItems.length === 0" description="该科目今天没有作业" />
</el-dialog>
<!-- 全屏:整块看板放大 -->
<el-dialog v-model="fullscreenBoard" fullscreen title="今日作业看板" class="board-fullscreen">
<div class="board board-lg" :style="fullscreenStyle">
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-title">{{ item.title }}</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
</li>
</ol>
</section>
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen">
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">今日作业看板</span>
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="fullscreenHostRef" class="board-host board-host-lg">
<div
ref="fullscreenInnerRef"
class="board board-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
>
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
:class="{ done: item.completed }"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
</li>
</ol>
</section>
</div>
</div>
</el-dialog>
@@ -468,7 +602,7 @@ onUnmounted(() => {
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
<el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
{{ student.name }}({{ student.username }})
{{ student.name }}
</el-checkbox>
</el-checkbox-group>
</div>
@@ -543,6 +677,27 @@ onUnmounted(() => {
margin: 0;
}
/* 作业页整体不滚动:看板靠自适应缩放铺满,避免"要上下翻页才能看全" */
.homework-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.homework-body {
flex: 1 1 auto;
min-height: 0;
}
.homework-body :deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---------------------------------------------------------------- 看板 */
.board-config {
@@ -560,15 +715,32 @@ onUnmounted(() => {
font-weight: 600;
}
/* 承载区:高度固定,里面靠 scale 适配,绝不出现滚动条 */
.board-host {
flex: 1 1 auto;
min-height: 240px;
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: center;
}
.board-host-lg {
height: calc(100vh - 150px);
}
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 15), 1fr));
gap: calc(var(--board-font, 15px) * 0.8);
width: 100%;
transform: scale(var(--board-scale, 1));
transform-origin: top center;
}
.board-lg {
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 16), 1fr));
gap: calc(var(--board-font, 15px) * 0.9);
}
.board-card {
@@ -577,8 +749,7 @@ onUnmounted(() => {
background: #1c1c1e;
color: #f5f5f7;
border-radius: 18px;
padding: calc(var(--board-font, 15px) * 0.9) calc(var(--board-font, 15px) * 1.1);
min-height: 120px;
padding: calc(var(--board-font, 15px) * 0.8) calc(var(--board-font, 15px) * 0.95);
cursor: zoom-in;
transition:
transform 0.16s ease,
@@ -596,7 +767,7 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: calc(var(--board-font, 15px) * 0.5);
margin-bottom: calc(var(--board-font, 15px) * 0.45);
}
.board-course {
@@ -625,13 +796,13 @@ onUnmounted(() => {
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--board-font, 15px) * 0.35);
gap: calc(var(--board-font, 15px) * 0.4);
flex: 1;
}
.board-item {
display: flex;
align-items: baseline;
align-items: flex-start;
gap: 8px;
font-size: var(--board-font, 15px);
line-height: 1.5;
@@ -649,12 +820,29 @@ onUnmounted(() => {
flex: 0 0 auto;
}
.board-title {
.board-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.board-title {
font-weight: 600;
word-break: break-word;
}
.board-item.done .board-title {
/* 作业要求:完整展示(不截断),字号略小以保持层级 */
.board-content {
font-size: calc(var(--board-font, 15px) * 0.82);
color: #c7c7cc;
white-space: pre-wrap;
word-break: break-word;
}
.board-item.done .board-title,
.board-item.done .board-content {
text-decoration: line-through;
color: #8e8e93;
}
@@ -675,7 +863,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: calc(var(--board-font, 15px) * 0.5);
margin-top: calc(var(--board-font, 15px) * 0.45);
font-size: calc(var(--board-font, 15px) * 0.7);
}
@@ -691,6 +879,31 @@ onUnmounted(() => {
padding: 6px 8px;
}
/* 全屏弹窗标题栏:标题 + 当天时间(时间绝对居中对齐) */
.board-dialog-head {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
padding-right: 48px;
}
.board-dialog-clock {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
font-weight: 500;
color: #6b7280;
white-space: nowrap;
}
.board-fullscreen :deep(.el-dialog__body) {
padding-top: 6px;
height: calc(100vh - 84px);
overflow: hidden;
}
.submission-toolbar {
display: flex;
align-items: center;
@@ -171,7 +171,8 @@ function remainText(minutes: number): string {
}
async function loadTerm(): Promise<void> {
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
currentWeek: 1,
maxWeek: 20,
});
+3 -1
View File
@@ -8,10 +8,12 @@ import type { IslandAppearance, IslandNotification, IslandState } from '@classhe
export interface HomeworkBoardSettings {
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
mode: 'board' | 'list';
/** 看板条目是否显示时间 */
/** 看板条目是否显示时间(同时也控制"今日作业看板"标题栏中间的当天时间) */
showTime: boolean;
/** 看板字号(px) */
fontSize: number;
/** 只看"今天布置"的作业(默认 true,避免昨天的过期作业混进来) */
todayOnly: boolean;
}
export interface DesktopStoredConfig {