feat(desktop-client): 课表改为 ClassIsland 风格今天时间轴(大时钟 + 当前课强调色高亮 + 进度条与倒计时 + 已结束/下一节标记),补充时间轴自动化校验与实拍

This commit is contained in:
LoveChengke committed 2026-09-12 14:15:00 +08:00
1 parent 219f6ae83a
commit 6b8563d0d3
10 files changed
+683 -50

No files matched your search

+21 -13
View File
@@ -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 未实测 ⚠️ |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 231 KiB

+15 -1
View File
@@ -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<void> {
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 () => {
+133 -1
View File
@@ -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<SmokeCheckResult> {
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<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -343,6 +474,7 @@ export function registerSmokeHooks(): void {
islandRealtimeScenario,
islandRealtimeCleanup,
islandReadState,
scheduleTimelineSelfTest,
sessionCleanup,
};
}
@@ -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<number | null>(null);
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 时间轴 / 周视图 切换(默认时间轴,对齐 ClassIsland 的"今天"观感) */
const activeTab = ref<'today' | 'week'>('today');
/** 当前时刻(每秒刷新,用于大时钟与"进行中/倒计时") */
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | 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<TimelineLesson[]>(() =>
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<string, ScheduleDto[]>();
@@ -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<void> {
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(() => {
<div>
<h2 class="page-title">我的课表</h2>
<p class="page-subtitle">
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
第 {{ week }} 周 · 今天 {{ todayItems.length }} 节 · 本周共 {{ items.length }} 节 ·
{{ courseCount }} 门课程
<el-tag v-if="fromCache" class="ml-8" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
<el-select v-model="week" style="width: 118px" @change="loadSchedules">
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
</el-select>
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)" />
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
下一周
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
<el-icon><ArrowRight /></el-icon>
</el-button>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
<el-table-column label="节次" width="110" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column
v-for="day in WEEKDAYS"
:key="day"
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
min-width="132"
>
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<el-tabs v-model="activeTab" class="schedule-tabs">
<!-- ---------------------------------------------------- 今天(时间轴) -->
<el-tab-pane label="今天" name="today">
<div class="hero">
<div class="hero-clock">
<span class="clock-main">{{ clockText }}</span>
<span class="clock-seconds">{{ clockSeconds }}</span>
</div>
<div class="hero-meta">
<div class="hero-date">{{ dateText }} · {{ weekdayText }}</div>
<div class="hero-week">第 {{ week }} 教学周{{ isCurrentWeek ? '' : '(查看中)' }}</div>
</div>
<div class="hero-status" :class="`status-${headline.kind}`">
<div class="status-title">{{ headline.title }}</div>
<div class="status-detail">{{ headline.detail }}</div>
<div v-if="headline.kind !== 'idle'" class="status-remain">
{{ headline.kind === 'current' ? '距下课' : '距上课' }} {{ remainText(headline.remain) }}
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
<el-card v-loading="loading" shadow="never" class="timeline-card">
<el-empty v-if="timeline.length === 0" description="今天没有课程安排" :image-size="90" />
<ul v-else class="timeline">
<li
v-for="lesson in timeline"
:key="lesson.id"
class="timeline-item"
:class="`is-${lesson.state}`"
:data-state="lesson.state"
>
<div class="timeline-time">
<span class="time-start">{{ lesson.start }}</span>
<span class="time-end">{{ lesson.end }}</span>
</div>
<div class="timeline-rail" aria-hidden="true"><span class="rail-dot"></span></div>
<div class="timeline-card-inner">
<div class="lesson-head">
<span class="lesson-course">{{ lesson.course }}</span>
<el-tag v-if="lesson.state === 'current'" size="small" effect="dark" class="lesson-tag">
正在上课
</el-tag>
<el-tag
v-else-if="lesson.state === 'next'"
size="small"
type="warning"
effect="plain"
class="lesson-tag"
>
下一节 · {{ remainText(lesson.remainMinutes) }}后
</el-tag>
<el-tag
v-else-if="lesson.state === 'past'"
size="small"
type="info"
effect="plain"
class="lesson-tag"
>
已结束
</el-tag>
</div>
<div class="lesson-meta">
<span>{{ lesson.location }}</span>
<span class="lesson-sep">·</span>
<span>{{ lesson.weekRange }}</span>
<template v-if="lesson.state === 'current'">
<span class="lesson-sep">·</span>
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
</template>
</div>
<el-progress
v-if="lesson.state === 'current'"
class="lesson-progress"
:percentage="lesson.progress"
:stroke-width="4"
:show-text="false"
color="#ffffff"
/>
</div>
</li>
</ul>
</el-card>
</el-tab-pane>
<!-- ---------------------------------------------------- 本周(周视图) -->
<el-tab-pane label="本周" name="week">
<el-card v-loading="loading" shadow="never" class="table-card">
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
<el-table v-else :data="periods" border size="small">
<el-table-column label="节次" width="110" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column
v-for="day in WEEKDAYS"
:key="day"
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
min-width="132"
>
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.schedule-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
/* ------------------------------------------------------------ 顶部概览卡(ClassIsland 的"当前课"卡片) */
.hero {
display: flex;
align-items: center;
gap: 18px;
padding: 16px 20px;
margin-bottom: 12px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: var(--ch-layer);
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card);
}
.hero-clock {
display: flex;
align-items: baseline;
gap: 2px;
min-width: 118px;
}
.clock-main {
font-size: 40px;
font-weight: 600;
letter-spacing: -1px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.clock-seconds {
font-size: 14px;
color: var(--ch-text-tertiary);
font-variant-numeric: tabular-nums;
}
.hero-meta {
flex: 0 0 auto;
padding-right: 18px;
border-right: 1px solid var(--ch-divider);
}
.hero-date {
font-size: 13.5px;
font-weight: 600;
}
.hero-week {
margin-top: 4px;
font-size: 12px;
color: var(--ch-text-secondary);
}
.hero-status {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
}
.hero-status.status-current {
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
color: #fff;
}
.hero-status.status-idle {
background: rgba(0, 0, 0, 0.03);
}
.status-title {
font-size: 15px;
font-weight: 600;
}
.status-detail {
margin-top: 3px;
font-size: 12.5px;
opacity: 0.85;
}
.status-remain {
margin-top: 6px;
font-size: 12.5px;
font-weight: 600;
}
.hero-status.status-current .status-detail,
.hero-status.status-current .status-remain {
opacity: 0.92;
}
/* ------------------------------------------------------------ 时间轴 */
.timeline-card :deep(.el-card__body) {
padding: 8px 16px 12px !important;
}
.timeline {
margin: 0;
padding: 0;
list-style: none;
}
.timeline-item {
display: flex;
align-items: stretch;
gap: 0;
padding: 8px 0;
}
.timeline-time {
flex: 0 0 66px;
padding-top: 10px;
text-align: right;
padding-right: 12px;
}
.time-start {
display: block;
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.time-end {
display: block;
font-size: 11.5px;
color: var(--ch-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 竖向轨道 + 节点:ClassIsland 时间轴的标志性观感 */
.timeline-rail {
position: relative;
flex: 0 0 18px;
display: flex;
justify-content: center;
}
.timeline-rail::before {
content: '';
position: absolute;
top: 0;
bottom: -16px;
width: 2px;
background: var(--ch-divider);
}
.timeline-item:last-child .timeline-rail::before {
bottom: 50%;
}
.rail-dot {
position: relative;
z-index: 1;
margin-top: 14px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--ch-border-strong);
}
.timeline-item.is-current .rail-dot {
border-color: var(--ch-accent);
background: var(--ch-accent);
box-shadow: 0 0 0 4px var(--ch-accent-soft-strong);
}
.timeline-item.is-past .rail-dot {
border-color: var(--ch-border);
background: var(--ch-bg);
}
.timeline-card-inner {
flex: 1;
min-width: 0;
padding: 10px 14px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: rgba(255, 255, 255, 0.6);
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
}
.timeline-item:hover .timeline-card-inner {
border-color: var(--ch-border-strong);
transform: translateY(-1px);
}
/* 正在进行:强调色实底(ClassIsland 用它突出"当前课") */
.timeline-item.is-current .timeline-card-inner {
border-color: transparent;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
box-shadow: 0 4px 12px rgba(15, 108, 189, 0.28);
}
.timeline-item.is-past .timeline-card-inner {
background: rgba(255, 255, 255, 0.35);
color: var(--ch-text-tertiary);
}
.lesson-head {
display: flex;
align-items: center;
gap: 8px;
}
.lesson-course {
font-size: 15px;
font-weight: 600;
}
.lesson-tag {
margin-left: auto;
}
.lesson-meta {
margin-top: 5px;
font-size: 12.5px;
color: var(--ch-text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.timeline-item.is-current .lesson-meta {
color: rgba(255, 255, 255, 0.9);
}
.timeline-item.is-past .lesson-meta {
color: var(--ch-text-tertiary);
}
.lesson-sep {
opacity: 0.5;
}
.lesson-remain {
font-weight: 600;
}
.lesson-progress {
margin-top: 8px;
}
.lesson-progress :deep(.el-progress-bar__outer) {
background: rgba(255, 255, 255, 0.28);
}
@media (max-width: 720px) {
.hero {
flex-wrap: wrap;
gap: 12px;
}
.hero-meta {
border-right: none;
padding-right: 0;
}
}
</style>
+2
View File
@@ -99,6 +99,8 @@ declare global {
unreadCount: number;
title: string;
}>;
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}