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

+33 -1
View File
@@ -2,7 +2,14 @@ import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
import type { DesktopStoredConfig } from '../types/desktop.js';
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
mode: 'board',
showTime: false,
fontSize: 15,
};
interface PersistedConfig {
serverUrl: string;
@@ -12,6 +19,8 @@ interface PersistedConfig {
tokenEncrypted: boolean;
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -20,8 +29,23 @@ const DEFAULT_CONFIG: PersistedConfig = {
token: null,
tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
};
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board';
const rawFont =
typeof input?.fontSize === 'number' && Number.isFinite(input.fontSize)
? input.fontSize
: DEFAULT_HOMEWORK_BOARD.fontSize;
return {
mode,
showTime: input?.showTime === true,
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
};
}
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
@@ -99,6 +123,7 @@ function readPersisted(): PersistedConfig {
token: typeof parsed.token === 'string' ? parsed.token : null,
tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -119,6 +144,7 @@ export function getConfig(): DesktopStoredConfig {
username: persisted.username,
token: decryptToken(persisted),
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
};
}
@@ -135,6 +161,12 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.island !== undefined) {
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
}
if (patch.homeworkBoard !== undefined) {
persisted.homeworkBoard = normalizeHomeworkBoard({
...persisted.homeworkBoard,
...patch.homeworkBoard,
});
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
+11 -1
View File
@@ -434,7 +434,12 @@ class IslandController {
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
const showing = this.state.active;
if (this.state.inClass && !this.isOpenable(showing)) break;
if (this.state.inClass && !this.isOpenable(showing)) {
logger.info(
`灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`,
);
break;
}
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
@@ -448,6 +453,11 @@ class IslandController {
}
const active = this.state.active;
if (active) {
if (this.state.mode !== 'expanded') {
logger.info(
`灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`,
);
}
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
this.isImmediate(active)
+34 -14
View File
@@ -1133,11 +1133,20 @@ async function runIslandChecks(
await sleep(ISLAND_TIMEOUTS.pill + 700);
const lingered = island.getState();
const lingeredVisible = islandWindow?.isVisible() ?? false;
// 断言"没有消失"即可:真人可能在这 15 秒里点了岛(那就从胶囊变成展开态),
// 但只要还看得见、还是同一条通知,就说明"常驻"成立。
record(
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
collapseBefore.mode === 'pill' &&
lingered.mode !== 'hidden' &&
lingeredVisible &&
lingered.active?.id === 'smoke-collapse-reopen',
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible} ` +
`active=${lingered.active?.id ?? '-'}`,
);
// 先确保处于胶囊态(真人在等待期间可能点开过),再验证"命中恢复 + 点击展开"
island.handleAction({ action: 'collapse' });
await sleep(500);
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
island.setInteractive(false);
@@ -2281,24 +2290,27 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
}
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
await runTrayAndShutdownChecks(win, record);
const failed = results.filter((item) => !item.ok);
const passed = results.length - failed.length;
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
if (resultFile) {
/**
* 把当前结果落盘 + 打印汇总。
* 为什么要写两次:托盘/退出清理用例会真的走一遍"退出应用",进程可能在收尾代码执行前就退出,
* 那样 ELECTRON_SMOKE_RESULT 就不会生成(重启校验会误报"结果=-1/0")。
* 所以在跑托盘用例之前先写一次,收尾再写一次(覆盖全部用例)。
*/
const writeResult = (): void => {
const failedItems = results.filter((item) => !item.ok);
const passedCount = results.length - failedItems.length;
console.log(`\n[SMOKE] ${passedCount}/${results.length} 项通过`);
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
if (!resultFile) return;
try {
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
fs.writeFileSync(
resultFile,
JSON.stringify(
{
passed,
passed: passedCount,
total: results.length,
ok: failed.length === 0,
ok: failedItems.length === 0,
results,
finishedAt: new Date().toISOString(),
appVersion: app.getVersion(),
@@ -2313,7 +2325,15 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
} catch (error) {
console.error('[SMOKE] 写入结果文件失败', error);
}
}
};
writeResult();
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
await runTrayAndShutdownChecks(win, record);
writeResult();
const failed = results.filter((item) => !item.ok);
app.exit(failed.length === 0 ? 0 : 1);
}
@@ -6,6 +6,7 @@ import {
type GradeDto,
type HomeworkDto,
type HomeworkStatusDto,
type HomeworkSubmissionsDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
@@ -44,6 +45,11 @@ export const homeworkApi = {
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
/** 提交名单 / 未交名单(教师与班级设备可用) */
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 }),
};
export const notificationApi = {
+24 -7
View File
@@ -350,6 +350,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
};
const created: string[] = [];
const failures: string[] = [];
let teacherToken = '';
try {
@@ -373,7 +374,9 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
{ start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' },
{ start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' },
{ start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' },
];
// 深夜/凌晨时相对时间会越过 23:59 / 00:00,被夹紧后 start >= end(服务端 422)。
// 这种探针构造不出来就跳过,"下一节"的断言按实际创建数量放宽。
].filter((probe) => probe.start < probe.end);
for (const probe of probes) {
const response = await fetch(`${serverUrl}/api/schedules`, {
method: 'POST',
@@ -388,11 +391,23 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
weekEnd: 30,
location: probe.location,
}),
}).then((result) => result.json());
const id = response?.data?.id;
});
const payload = await response.json().catch(() => null);
const id = payload?.data?.id;
if (id) created.push(id);
// 记录失败原因(限流 429 / 时间非法 400 等),否则只看到一个"2/3"没法排查
else {
failures.push(
`${probe.start}-${probe.end} status=${response.status} ${payload?.message ?? ''}`.trim(),
);
}
}
if (created.length < probes.length) {
return {
ok: false,
detail: `探针课表创建失败(${created.length}/${probes.length})${failures.length > 0 ? `:${failures.join(';')}` : ''}`,
};
}
if (created.length < 3) return { ok: false, detail: `探针课表创建失败(${created.length}/3)` };
await router.push('/schedule');
@@ -420,7 +435,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(),
})),
};
if (snapshot.hasCurrent && snapshot.count >= 3) {
if (snapshot.hasCurrent && snapshot.count >= probes.length) {
dom = snapshot;
break;
}
@@ -432,13 +447,15 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束'));
const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节'));
const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课');
// 深夜/凌晨可能只构造出 2 个探针("下一节"跨过 23:59),此时不要求存在该卡片
const expectNextProbe = probes.length >= 3;
const ok =
dom.count >= 3 &&
dom.count >= probes.length &&
dom.hasCurrent &&
dom.hasProgress &&
countdown &&
hasPast &&
hasNext &&
(!expectNextProbe || hasNext) &&
/^\d{2}:\d{2}$/.test(dom.clock);
return {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, type HomeworkDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
@@ -12,6 +12,9 @@ const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
const DEFAULT_BOARD = { mode: 'board' as 'board' | 'list', showTime: false, fontSize: 15 };
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
const filter = ref<'all' | 'pending' | 'done'>('all');
@@ -21,6 +24,15 @@ const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
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 savingBoard = ref(false);
/** 全屏放大的科目(null = 未打开) */
const fullscreenCourse = ref<string | null>(null);
const fullscreenBoard = ref(false);
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);
@@ -30,6 +42,62 @@ const filtered = computed(() => {
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
/**
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
* 科目内部按发布时间倒序,卡片顺序按学科常见顺序排列。
*/
const COURSE_ORDER = [
'语文',
'数学',
'英语',
'物理',
'化学',
'生物',
'政治',
'历史',
'地理',
'体育',
'音乐',
'美术',
'信息技术',
];
interface BoardColumn {
course: string;
items: HomeworkDto[];
pending: number;
}
const boardColumns = computed<BoardColumn[]>(() => {
const groups = new Map<string, HomeworkDto[]>();
for (const item of filtered.value) {
const key = item.course?.name ?? '其他';
const list = groups.get(key) ?? [];
list.push(item);
groups.set(key, list);
}
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
course,
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
pending: items.filter((item) => item.completed !== true).length,
}));
const rank = (name: string): number => {
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
return index < 0 ? COURSE_ORDER.length : index;
};
return columns.sort((a, b) => rank(a.course) - rank(b.course) || a.course.localeCompare(b.course));
});
/** 看板字号:主看板用设定值,全屏放大 1.7 倍 */
const boardStyle = computed(() => ({ '--board-font': `${boardFontSize.value}px` }));
const fullscreenStyle = computed(() => ({ '--board-font': `${Math.round(boardFontSize.value * 1.7)}px` }));
const fullscreenItems = computed(() =>
fullscreenCourse.value
? (boardColumns.value.find((column) => column.course === fullscreenCourse.value)?.items ?? [])
: [],
);
async function loadHomework(): Promise<void> {
loading.value = true;
try {
@@ -52,11 +120,49 @@ async function loadHomework(): Promise<void> {
}
}
/** 读取看板偏好(主进程配置为单一事实来源) */
async function loadBoardSettings(): Promise<void> {
const config = await window.desktop?.getConfig?.();
const saved = config?.homeworkBoard;
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.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
}
}
async function saveBoardSettings(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingBoard.value = true;
try {
await window.desktop.saveConfig({
homeworkBoard: {
mode: viewMode.value,
showTime: boardShowTime.value,
fontSize: boardFontSize.value,
},
});
} finally {
savingBoard.value = false;
}
}
function openDetail(item: HomeworkDto): void {
current.value = item;
detailVisible.value = true;
}
function openCourseFullscreen(column: BoardColumn): void {
fullscreenCourse.value = column.course;
}
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
function openBoardFullscreen(): void {
fullscreenCourse.value = null;
fullscreenBoard.value = true;
}
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
if (!item) return;
if (appStore.offline) {
@@ -77,15 +183,7 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
completed: next,
updatedAt: new Date().toISOString(),
};
ElMessage.success(
auth.isClassSession
? next
? '已按全班标记为完成'
: '已取消全班的完成标记'
: next
? '已标记为完成'
: '已取消完成标记',
);
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败');
} finally {
@@ -93,6 +191,66 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
}
}
/* ------------------------------------------------------------ 未交名单(班级设备) */
const submissionsVisible = ref(false);
const submissionsLoading = ref(false);
const submissionsSaving = ref(false);
const submissionsTarget = ref<HomeworkDto | null>(null);
const submissions = ref<HomeworkSubmissionDto[]>([]);
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
const notSubmittedIds = ref<string[]>([]);
const notSubmittedCount = computed(() => notSubmittedIds.value.length);
async function openSubmissions(item: HomeworkDto): Promise<void> {
submissionsTarget.value = item;
submissionsVisible.value = true;
submissionsLoading.value = true;
try {
const result = await homeworkApi.submissions(item.id);
submissions.value = result.students;
// 默认沿用服务端已有的未交数据(老师只需改动的部分)
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
submissions.value = [];
notSubmittedIds.value = [];
} finally {
submissionsLoading.value = false;
}
}
function markAllSubmitted(): void {
notSubmittedIds.value = [];
}
function markAllNotSubmitted(): void {
notSubmittedIds.value = submissions.value.map((student) => student.userId);
}
async function saveSubmissions(): Promise<void> {
const target = submissionsTarget.value;
if (!target) return;
if (appStore.offline) {
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
return;
}
submissionsSaving.value = true;
try {
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
submissions.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
target.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 onHomeworkEvent(): void {
void loadHomework();
}
@@ -109,6 +267,7 @@ function onRecovered(): void {
}
onMounted(async () => {
await loadBoardSettings();
await loadHomework();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
@@ -119,6 +278,7 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
void saveBoardSettings();
});
</script>
@@ -138,12 +298,77 @@ onUnmounted(() => {
<el-radio-button value="pending">未完成</el-radio-button>
<el-radio-button value="done">已完成</el-radio-button>
</el-radio-group>
<el-radio-group v-model="viewMode" @change="saveBoardSettings">
<el-radio-button value="board">看板</el-radio-button>
<el-radio-button value="list">列表</el-radio-button>
</el-radio-group>
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
</div>
</div>
<!-- 看板外观:显示时间 / 字号(即时生效并持久化) -->
<el-card v-if="viewMode === 'board'" shadow="never" class="board-config">
<div class="board-config-row">
<span class="board-config-label">看板设置</span>
<el-switch
v-model="boardShowTime"
active-text="显示时间"
inactive-text="隐藏时间"
@change="saveBoardSettings"
/>
<span class="board-config-label">字号</span>
<el-slider
v-model="boardFontSize"
:min="11"
:max="28"
:step="1"
style="width: 200px"
@change="saveBoardSettings"
/>
<span class="text-muted">{{ boardFontSize }}px</span>
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
<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="没有符合条件的作业" />
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
<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>
<!-- 列表模式(默认表格视图) -->
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
@@ -156,29 +381,103 @@ onUnmounted(() => {
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
未交名单
</el-button>
<el-button
v-else
link
:type="row.completed ? 'warning' : 'primary'"
:loading="submitting"
@click.stop="toggleComplete(row)"
>
{{
row.completed
? auth.isClassSession
? '取消全班完成'
: '取消完成'
: auth.isClassSession
? '全班标记完成'
: '标记完成'
}}
{{ row.completed ? '取消完成' : '标记完成' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 全屏:单个科目放大 -->
<el-dialog
v-model="fullscreenCourse"
fullscreen
: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)"
>
<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="该科目暂无作业" />
</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>
</div>
</el-dialog>
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
<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">未交 {{ notSubmittedCount }} / {{ submissions.length }} 人</span>
</div>
<div v-loading="submissionsLoading" class="submission-list">
<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 }})
</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>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
<template v-if="current">
<el-descriptions :column="2" border size="small">
@@ -207,14 +506,21 @@ onUnmounted(() => {
<div class="mt-16">
<el-button
v-if="auth.isClassSession"
type="primary"
:disabled="appStore.offline"
@click="openSubmissions(current)"
>
维护未交名单
</el-button>
<el-button
v-else
type="primary"
:loading="submitting"
:disabled="appStore.offline"
@click="toggleComplete(current)"
>
{{
current.completed ? '取消完成标记' : auth.isClassSession ? '全班标记为已完成' : '标记为已完成'
}}
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
</el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div>
@@ -232,8 +538,177 @@ onUnmounted(() => {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
border-radius: 8px;
border-radius: 12px;
padding: 12px;
margin: 0;
}
/* ---------------------------------------------------------------- 看板 */
.board-config {
margin-bottom: 12px;
}
.board-config-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.board-config-label {
font-weight: 600;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.board-lg {
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 20px;
}
.board-card {
display: flex;
flex-direction: column;
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;
cursor: zoom-in;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
}
.board-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
}
.board-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: calc(var(--board-font, 15px) * 0.5);
}
.board-course {
font-size: calc(var(--board-font, 15px) * 0.95);
font-weight: 600;
color: #ffffff;
}
.board-badge {
min-width: calc(var(--board-font, 15px) * 1.4);
height: calc(var(--board-font, 15px) * 1.4);
border-radius: 999px;
background: #ff9f0a;
color: #1c1c1e;
font-size: calc(var(--board-font, 15px) * 0.72);
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
}
.board-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--board-font, 15px) * 0.35);
flex: 1;
}
.board-item {
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--board-font, 15px);
line-height: 1.5;
border-radius: 10px;
padding: 2px 4px;
cursor: pointer;
}
.board-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.board-index {
color: #8e8e93;
flex: 0 0 auto;
}
.board-title {
flex: 1 1 auto;
word-break: break-word;
}
.board-item.done .board-title {
text-decoration: line-through;
color: #8e8e93;
}
.board-time {
flex: 0 0 auto;
font-size: calc(var(--board-font, 15px) * 0.72);
color: #8e8e93;
}
.board-done-tag {
flex: 0 0 auto;
font-size: calc(var(--board-font, 15px) * 0.66);
color: #32d74b;
}
.board-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: calc(var(--board-font, 15px) * 0.5);
font-size: calc(var(--board-font, 15px) * 0.7);
}
.board-card-foot .text-muted {
color: #8e8e93;
}
.board-zoom {
color: #0a84ff;
}
.board-list-lg .board-item {
padding: 6px 8px;
}
.submission-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 12px;
}
.submission-list :deep(.el-checkbox-group) {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
@@ -4,6 +4,7 @@ import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
WEEK_PARITY_LABELS,
buildWeekOptions,
formatWeekRange,
resolveClassStatus,
@@ -73,6 +74,8 @@ interface TimelineLesson {
course: string;
location: string;
weekRange: string;
/** 单双周标签('单周' / '双周';每周的课为空串) */
parityLabel: string;
state: LessonState;
/** 进行中:距下课剩余;下一节:距上课剩余(分钟) */
remainMinutes: number;
@@ -102,6 +105,8 @@ const timeline = computed<TimelineLesson[]>(() =>
course: item.course?.name ?? '课程',
location: item.location ?? '未填地点',
weekRange: formatWeekRange(item.weekStart, item.weekEnd),
/** 单双周标签(每周的课不显示) */
parityLabel: (item.weekParity ?? 'ALL') === 'ALL' ? '' : WEEK_PARITY_LABELS[item.weekParity],
state,
remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes,
progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0,
@@ -324,6 +329,13 @@ onUnmounted(() => {
<span>{{ lesson.location }}</span>
<span class="lesson-sep">·</span>
<span>{{ lesson.weekRange }}</span>
<!-- 单双周:只有单周/双周上的课才打标签,避免"每周"的课被噪音刷屏 -->
<template v-if="lesson.parityLabel">
<span class="lesson-sep">·</span>
<el-tag size="small" effect="plain" class="lesson-parity">
{{ lesson.parityLabel }}
</el-tag>
</template>
<template v-if="lesson.state === 'current'">
<span class="lesson-sep">·</span>
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
@@ -623,6 +635,15 @@ onUnmounted(() => {
opacity: 0.5;
}
/* 单双周标签:比正文小一号,颜色跟随主题色,避免抢标题 */
.lesson-parity {
height: 18px;
padding: 0 6px;
font-size: 11px;
color: var(--ch-accent, #409eff);
border-color: currentColor;
}
.lesson-remain {
font-weight: 600;
}
+12
View File
@@ -4,6 +4,16 @@
*/
import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
/** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings {
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
mode: 'board' | 'list';
/** 看板条目是否显示时间 */
showTime: boolean;
/** 看板字号(px) */
fontSize: number;
}
export interface DesktopStoredConfig {
/** 后端服务地址,例如 http://127.0.0.1:4000 */
serverUrl: string;
@@ -13,6 +23,8 @@ export interface DesktopStoredConfig {
token: string | null;
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
island: IslandAppearance;
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
homeworkBoard: HomeworkBoardSettings;
}
export interface DesktopAppInfo {