diff --git a/README.md b/README.md index fc50ca9..a563556 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 33/33) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 34/34) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -173,7 +173,7 @@ pnpm verify:desktop # 另开一个终端先启动后端:pnpm dev:server pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:68 项 pnpm verify:web # Web 管理端真实点击(含手机小屏适配、叫人入口、紧急通知全屏警告):16 项 -pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路):33 项 +pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, @@ -216,7 +216,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | | `pnpm verify:e2e` | 后端端到端验收(68 项,需服务端已启动) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,16 项,含手机小屏适配与叫人入口) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(33 项,含灵动岛动画/收回/已读/作业/叫人、像素级校验) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | @@ -363,17 +363,25 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 - 字体 `Segoe UI Variable / 微软雅黑 UI`;表格表头无底色、行分隔为 1px 细线;滚动条改为细圆角 - Element Plus 变量(`--el-color-primary`、`--el-border-radius-base` 等)整体对齐上述规范 -实拍(`docs/screenshots/client/`): +**课表「今天」时间轴(ClassIsland 标志性观感)**,`今天 / 本周` 双标签: -| 页面 | 截图 | -| ---- | ----------------------------------------------------------- | -| 课表 | ![客户端课表](docs/screenshots/client/01-schedule.png) | -| 作业 | ![客户端作业](docs/screenshots/client/02-homeworks.png) | -| 通知 | ![客户端通知](docs/screenshots/client/03-notifications.png) | -| 设置 | ![客户端设置](docs/screenshots/client/05-settings.png) | +| 元素 | 说明 | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 大时钟 | 时:分 大字 + 秒小字(每秒刷新)+ 日期 / 星期 / 第 N 教学周 | +| 当前状态卡 | 「正在上 XXX」+ 时段地点 + **距下课 X 分钟**;无课时显示「今天的课都上完了 / 今天没有课程安排」 | +| 时间轴 | 左侧时间栏(开始/结束)+ 竖向轨道与节点 + 右侧课卡:**已结束**(置灰)、**正在上课**(强调色实底 + 进度条 + 距下课倒计时)、**下一节**(橙色标记 + 还有多久开始) | +| 周视图 | 保留原"节次 × 星期"表格(`本周` 标签) | -> 后续可继续深化:课表改为 ClassIsland 那种"垂直时间轴 + 当前课高亮卡片"(现为周视图表格)、 -> 迷你悬浮课表窗口、上课/下课提醒动画。 +实拍(`docs/screenshots/client/`,时间轴图为造了"已结束/正在上/下一节"三节课后的抓图): + +| 页面 | 截图 | +| --------------------- | ------------------------------------------------------------ | +| 课表 · 今天(时间轴) | ![客户端课表时间轴](docs/screenshots/client/01-schedule.png) | +| 作业 | ![客户端作业](docs/screenshots/client/02-homeworks.png) | +| 通知 | ![客户端通知](docs/screenshots/client/03-notifications.png) | +| 设置 | ![客户端设置](docs/screenshots/client/05-settings.png) | + +> 后续可继续深化:迷你悬浮课表窗口(ClassIsland 的桌面课表条)、上课/下课提醒动画与铃声。 ### 离线缓存与自动同步 @@ -645,7 +653,7 @@ pnpm db:migrate && pnpm db:seed | ---------------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **68/68** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 叫人入口 + 紧急通知 + PWA) | **16/16** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 像素级校验) | **33/33** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | diff --git a/docs/screenshots/client/01-schedule.png b/docs/screenshots/client/01-schedule.png index d4d432b..23a27e2 100644 Binary files a/docs/screenshots/client/01-schedule.png and b/docs/screenshots/client/01-schedule.png differ diff --git a/docs/screenshots/client/02-homeworks.png b/docs/screenshots/client/02-homeworks.png index 6611d9f..40df6c7 100644 Binary files a/docs/screenshots/client/02-homeworks.png and b/docs/screenshots/client/02-homeworks.png differ diff --git a/docs/screenshots/client/03-notifications.png b/docs/screenshots/client/03-notifications.png index 2d89024..4d107b1 100644 Binary files a/docs/screenshots/client/03-notifications.png and b/docs/screenshots/client/03-notifications.png differ diff --git a/docs/screenshots/client/04-grades.png b/docs/screenshots/client/04-grades.png index ea69225..f0bd03c 100644 Binary files a/docs/screenshots/client/04-grades.png and b/docs/screenshots/client/04-grades.png differ diff --git a/docs/screenshots/client/05-settings.png b/docs/screenshots/client/05-settings.png index 70f1bc4..cbc40f5 100644 Binary files a/docs/screenshots/client/05-settings.png and b/docs/screenshots/client/05-settings.png differ diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 39319f5..e66fe92 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs'; +import fs from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; import type { BrowserWindow, NativeImage } from 'electron'; @@ -765,6 +765,20 @@ export async function runSmokeTest(win: BrowserWindow): Promise { const realtime = await runIslandRealtimeCheck(win); record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail); record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail); + + // ClassIsland 风格「今天」时间轴:造课 → 断言高亮/倒计时/大时钟 → 清理 + const timeline = await win.webContents.executeJavaScript( + `(async () => { + if (!window.__classhelperSmoke__?.scheduleTimelineSelfTest) return { ok: false, detail: '缺少时间轴自检钩子' }; + return await window.__classhelperSmoke__.scheduleTimelineSelfTest(); + })()`, + ); + record( + '课表「今天」时间轴(当前课高亮/倒计时/大时钟)', + Boolean(timeline?.ok), + String(timeline?.detail ?? ''), + ); + // 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应) const navigation = await win.webContents.executeJavaScript( `(async () => { diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 5e3c688..ed4c387 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -1,4 +1,4 @@ -import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; +import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; import { fetchWithCache } from './index.js'; export interface SmokeCheckResult { @@ -318,6 +318,137 @@ export async function islandReadState(notificationId: string): Promise<{ }; } +/** + * 冒烟自检 6(需后端在线):ClassIsland 风格"今天"时间轴。 + * + * 造三节覆盖今天的课(已结束 / 正在上 / 即将开始),切到课表页读取 DOM, + * 断言:时间轴渲染出卡片、正在上的那张是"进行中"卡片并带倒计时与进度条、 + * "已结束"/"下一节"标记存在、大时钟在走。用后删除探针课表。 + */ +export async function scheduleTimelineSelfTest(): Promise { + const [{ useAuthStore }, { router }] = await Promise.all([ + import('../stores/auth.js'), + import('../router/index.js'), + ]); + const auth = useAuthStore(); + const serverUrl = 'http://127.0.0.1:4000'; + const classId = auth.classId ?? null; + if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' }; + + const toHHmm = (minutes: number): string => { + const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes)); + return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`; + }; + + const created: string[] = []; + let teacherToken = ''; + + try { + const login = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), + }).then((response) => response.json()); + teacherToken = login?.data?.token ?? ''; + if (!teacherToken) return { ok: false, detail: '教师登录失败' }; + + const courses = await fetch(`${serverUrl}/api/courses?classId=${classId}`, { + headers: { authorization: `Bearer ${teacherToken}` }, + }).then((response) => response.json()); + const courseId = courses?.data?.[0]?.id; + if (!courseId) return { ok: false, detail: '该班级没有课程,无法构造课表' }; + + const nowMinutes = new Date().getHours() * 60 + new Date().getMinutes(); + const dayOfWeek = new Date().getDay() === 0 ? 7 : new Date().getDay(); + const probes = [ + { 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: '时间轴自检 · 下一节' }, + ]; + for (const probe of probes) { + const response = await fetch(`${serverUrl}/api/schedules`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` }, + body: JSON.stringify({ + classId, + courseId, + dayOfWeek, + startTime: probe.start, + endTime: probe.end, + weekStart: 1, + weekEnd: 30, + location: probe.location, + }), + }).then((result) => result.json()); + const id = response?.data?.id; + if (id) created.push(id); + } + if (created.length < 3) return { ok: false, detail: `探针课表创建失败(${created.length}/3)` }; + + await router.push('/schedule'); + + let dom: { + count: number; + hasCurrent: boolean; + currentText: string; + hasProgress: boolean; + clock: string; + headline: string; + tags: { state: string; text: string }[]; + } | null = null; + for (let attempt = 0; attempt < 24; attempt += 1) { + const items = Array.from(document.querySelectorAll('.timeline-item')); + const current = document.querySelector('.timeline-item.is-current'); + const snapshot = { + count: items.length, + hasCurrent: Boolean(current), + currentText: (current?.textContent ?? '').replace(/\s+/g, ' ').trim(), + hasProgress: Boolean(current?.querySelector('.lesson-progress')), + clock: document.querySelector('.clock-main')?.textContent?.trim() ?? '', + headline: document.querySelector('.status-title')?.textContent?.trim() ?? '', + tags: items.map((node) => ({ + state: node.getAttribute('data-state') ?? '', + text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(), + })), + }; + if (snapshot.hasCurrent && snapshot.count >= 3) { + dom = snapshot; + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + if (!dom) return { ok: false, detail: '时间轴未渲染出"进行中"卡片' }; + + 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('即将下课'); + const ok = + dom.count >= 3 && + dom.hasCurrent && + dom.hasProgress && + countdown && + hasPast && + hasNext && + /^\d{2}:\d{2}$/.test(dom.clock); + + return { + ok, + detail: + `卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` + + `已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"`, + }; + } catch (error) { + return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` }; + } finally { + for (const id of created) { + await fetch(`${serverUrl}/api/schedules/${id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${teacherToken}` }, + }).catch(() => undefined); + } + } +} /** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */ export async function sessionCleanup(): Promise { const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ @@ -343,6 +474,7 @@ export function registerSmokeHooks(): void { islandRealtimeScenario, islandRealtimeCleanup, islandReadState, + scheduleTimelineSelfTest, sessionCleanup, }; } diff --git a/packages/desktop-client/src/renderer/views/ScheduleView.vue b/packages/desktop-client/src/renderer/views/ScheduleView.vue index 22d606c..92cf330 100644 --- a/packages/desktop-client/src/renderer/views/ScheduleView.vue +++ b/packages/desktop-client/src/renderer/views/ScheduleView.vue @@ -6,6 +6,8 @@ import { WEEKDAY_LABELS, buildWeekOptions, formatWeekRange, + resolveClassStatus, + timeToMinutes, type ScheduleDto, type ScheduleWeekView, } from '@classhelper/shared'; @@ -26,6 +28,114 @@ const week = ref(appStore.currentWeek); const fromCache = ref(false); const updatedAt = ref(null); const weekOptions = ref(buildWeekOptions(20)); +/** 时间轴 / 周视图 切换(默认时间轴,对齐 ClassIsland 的"今天"观感) */ +const activeTab = ref<'today' | 'week'>('today'); + +/** 当前时刻(每秒刷新,用于大时钟与"进行中/倒计时") */ +const now = ref(new Date()); +let clockTimer: ReturnType | null = null; + +const todayWeekday = computed(() => { + const day = now.value.getDay(); + return day === 0 ? 7 : day; +}); + +const clockText = computed(() => + now.value.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }), +); +const clockSeconds = computed(() => String(now.value.getSeconds()).padStart(2, '0')); +const dateText = computed(() => + now.value.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }), +); +const weekdayText = computed(() => WEEKDAY_LABELS[todayWeekday.value] ?? ''); + +/** 查看的是否为当前教学周(只有当前周才计算"进行中") */ +const isCurrentWeek = computed(() => week.value === appStore.currentWeek); + +const todayItems = computed(() => + [...items.value] + .filter((item) => item.dayOfWeek === todayWeekday.value) + .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)), +); + +const classStatus = computed(() => + isCurrentWeek.value + ? resolveClassStatus(todayItems.value, now.value, week.value) + : { inClass: false, current: null, next: null }, +); + +type LessonState = 'past' | 'current' | 'next' | 'upcoming'; + +interface TimelineLesson { + id: string; + start: string; + end: string; + course: string; + location: string; + weekRange: string; + state: LessonState; + /** 进行中:距下课剩余;下一节:距上课剩余(分钟) */ + remainMinutes: number; + /** 进行中:本节课进度 0~100 */ + progress: number; +} + +const timeline = computed(() => + todayItems.value.map((item) => { + const start = timeToMinutes(item.startTime); + const end = timeToMinutes(item.endTime); + const nowMinutes = now.value.getHours() * 60 + now.value.getMinutes(); + const isCurrent = + isCurrentWeek.value && classStatus.value.current?.scheduleId === item.id && nowMinutes < end; + const isNext = isCurrentWeek.value && classStatus.value.next?.scheduleId === item.id; + const state: LessonState = isCurrent + ? 'current' + : nowMinutes >= end + ? 'past' + : isNext + ? 'next' + : 'upcoming'; + return { + id: item.id, + start: item.startTime, + end: item.endTime, + course: item.course?.name ?? '课程', + location: item.location ?? '未填地点', + weekRange: formatWeekRange(item.weekStart, item.weekEnd), + state, + remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes, + progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0, + }; + }), +); + +/** 头部摘要:正在上课 / 下一节 */ +const headline = computed(() => { + const status = classStatus.value; + if (status.current) { + return { + title: `正在上 ${status.current.courseName}`, + detail: `${status.current.startTime}-${status.current.endTime}${status.current.location ? ` · ${status.current.location}` : ''}`, + remain: timeline.value.find((item) => item.state === 'current')?.remainMinutes ?? 0, + kind: 'current' as const, + }; + } + if (status.next) { + const remain = timeline.value.find((item) => item.state === 'next')?.remainMinutes ?? 0; + return { + title: `下一节 ${status.next.courseName}`, + detail: `${status.next.startTime} 开始${status.next.location ? ` · ${status.next.location}` : ''}`, + remain, + kind: 'next' as const, + }; + } + return { + title: todayItems.value.length > 0 ? '今天的课都上完了' : '今天没有课程安排', + detail: todayItems.value.length > 0 ? '好好休息一下,别忘了复习' : '可以看看作业和通知', + remain: 0, + kind: 'idle' as const, + }; +}); const periods = computed(() => { const buckets = new Map(); @@ -47,13 +157,14 @@ function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): Schedul return period.items.filter((item) => item.dayOfWeek === dayOfWeek); } -const todayWeekday = computed(() => { - const day = new Date().getDay(); - return day === 0 ? 7 : day; -}); - const courseCount = computed(() => new Set(items.value.map((item) => item.courseId)).size); +function remainText(minutes: number): string { + if (minutes <= 0) return '即将下课'; + if (minutes < 60) return `${minutes} 分钟`; + return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`; +} + async function loadTerm(): Promise { const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), { currentWeek: 1, @@ -110,11 +221,16 @@ function onRecovered(): void { onMounted(async () => { await loadTerm(); await loadSchedules(); + clockTimer = setInterval(() => { + now.value = new Date(); + }, 1000); realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent); appStore.onServerRecovered(onRecovered); }); onUnmounted(() => { + if (clockTimer) clearInterval(clockTimer); + clockTimer = null; realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent); appStore.offServerRecovered(onRecovered); }); @@ -126,47 +242,408 @@ onUnmounted(() => {

我的课表

- 第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程 - 离线缓存 + 第 {{ week }} 周 · 今天 {{ todayItems.length }} 节 · 本周共 {{ items.length }} 节 · + {{ courseCount }} 门课程 + 离线缓存

- 上一周 - + + - 下一周 - + 刷新
- - - - - - - - - - - + + + + + +
    +
  • +
    + {{ lesson.start }} + {{ lesson.end }} +
    + +
    +
    + {{ lesson.course }} + + 正在上课 + + + 下一节 · {{ remainText(lesson.remainMinutes) }}后 + + + 已结束 + +
    +
    + {{ lesson.location }} + · + {{ lesson.weekRange }} + +
    + +
    +
  • +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 2d00ad0..4c65044 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -99,6 +99,8 @@ declare global { unreadCount: number; title: string; }>; + /** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */ + scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>; }; }