feat(desktop-client): 课表改为 ClassIsland 风格今天时间轴(大时钟 + 当前课强调色高亮 + 进度条与倒计时 + 已结束/下一节标记),补充时间轴自动化校验与实拍
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 33/33) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 34/34) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -173,7 +173,7 @@ pnpm verify:desktop
|
|||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:68 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:68 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含手机小屏适配、叫人入口、紧急通知全屏警告):16 项
|
pnpm verify:web # Web 管理端真实点击(含手机小屏适配、叫人入口、紧急通知全屏警告):16 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路):33 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
||||||
@@ -216,7 +216,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(68 项,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(68 项,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,16 项,含手机小屏适配与叫人入口) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,16 项,含手机小屏适配与叫人入口) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(33 项,含灵动岛动画/收回/已读/作业/叫人、像素级校验) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||||
@@ -363,17 +363,25 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
- 字体 `Segoe UI Variable / 微软雅黑 UI`;表格表头无底色、行分隔为 1px 细线;滚动条改为细圆角
|
- 字体 `Segoe UI Variable / 微软雅黑 UI`;表格表头无底色、行分隔为 1px 细线;滚动条改为细圆角
|
||||||
- Element Plus 变量(`--el-color-primary`、`--el-border-radius-base` 等)整体对齐上述规范
|
- Element Plus 变量(`--el-color-primary`、`--el-border-radius-base` 等)整体对齐上述规范
|
||||||
|
|
||||||
实拍(`docs/screenshots/client/`):
|
**课表「今天」时间轴(ClassIsland 标志性观感)**,`今天 / 本周` 双标签:
|
||||||
|
|
||||||
| 页面 | 截图 |
|
| 元素 | 说明 |
|
||||||
| ---- | ----------------------------------------------------------- |
|
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 课表 |  |
|
| 大时钟 | 时:分 大字 + 秒小字(每秒刷新)+ 日期 / 星期 / 第 N 教学周 |
|
||||||
| 作业 |  |
|
| 当前状态卡 | 「正在上 XXX」+ 时段地点 + **距下课 X 分钟**;无课时显示「今天的课都上完了 / 今天没有课程安排」 |
|
||||||
| 通知 |  |
|
| 时间轴 | 左侧时间栏(开始/结束)+ 竖向轨道与节点 + 右侧课卡:**已结束**(置灰)、**正在上课**(强调色实底 + 进度条 + 距下课倒计时)、**下一节**(橙色标记 + 还有多久开始) |
|
||||||
| 设置 |  |
|
| 周视图 | 保留原"节次 × 星期"表格(`本周` 标签) |
|
||||||
|
|
||||||
> 后续可继续深化:课表改为 ClassIsland 那种"垂直时间轴 + 当前课高亮卡片"(现为周视图表格)、
|
实拍(`docs/screenshots/client/`,时间轴图为造了"已结束/正在上/下一节"三节课后的抓图):
|
||||||
> 迷你悬浮课表窗口、上课/下课提醒动画。
|
|
||||||
|
| 页面 | 截图 |
|
||||||
|
| --------------------- | ------------------------------------------------------------ |
|
||||||
|
| 课表 · 今天(时间轴) |  |
|
||||||
|
| 作业 |  |
|
||||||
|
| 通知 |  |
|
||||||
|
| 设置 |  |
|
||||||
|
|
||||||
|
> 后续可继续深化:迷你悬浮课表窗口(ClassIsland 的桌面课表条)、上课/下课提醒动画与铃声。
|
||||||
|
|
||||||
### 离线缓存与自动同步
|
### 离线缓存与自动同步
|
||||||
|
|
||||||
@@ -645,7 +653,7 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| ---------------------------------------------------------------------------------- | ----------------------------------- |
|
| ---------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **68/68** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **68/68** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 叫人入口 + 紧急通知 + PWA) | **16/16** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 叫人入口 + 紧急通知 + PWA) | **16/16** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 像素级校验) | **33/33** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 231 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import type { BrowserWindow, NativeImage } 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);
|
const realtime = await runIslandRealtimeCheck(win);
|
||||||
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
|
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
|
||||||
record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail);
|
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 当路由名导致点击无反应)
|
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
||||||
const navigation = await win.webContents.executeJavaScript(
|
const navigation = await win.webContents.executeJavaScript(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
|
|||||||
@@ -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';
|
import { fetchWithCache } from './index.js';
|
||||||
|
|
||||||
export interface SmokeCheckResult {
|
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> {
|
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||||
@@ -343,6 +474,7 @@ export function registerSmokeHooks(): void {
|
|||||||
islandRealtimeScenario,
|
islandRealtimeScenario,
|
||||||
islandRealtimeCleanup,
|
islandRealtimeCleanup,
|
||||||
islandReadState,
|
islandReadState,
|
||||||
|
scheduleTimelineSelfTest,
|
||||||
sessionCleanup,
|
sessionCleanup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
buildWeekOptions,
|
buildWeekOptions,
|
||||||
formatWeekRange,
|
formatWeekRange,
|
||||||
|
resolveClassStatus,
|
||||||
|
timeToMinutes,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -26,6 +28,114 @@ const week = ref(appStore.currentWeek);
|
|||||||
const fromCache = ref(false);
|
const fromCache = ref(false);
|
||||||
const updatedAt = ref<number | null>(null);
|
const updatedAt = ref<number | null>(null);
|
||||||
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
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 periods = computed(() => {
|
||||||
const buckets = new Map<string, ScheduleDto[]>();
|
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);
|
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);
|
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> {
|
async function loadTerm(): Promise<void> {
|
||||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
|
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
|
||||||
currentWeek: 1,
|
currentWeek: 1,
|
||||||
@@ -110,11 +221,16 @@ function onRecovered(): void {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTerm();
|
await loadTerm();
|
||||||
await loadSchedules();
|
await loadSchedules();
|
||||||
|
clockTimer = setInterval(() => {
|
||||||
|
now.value = new Date();
|
||||||
|
}, 1000);
|
||||||
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (clockTimer) clearInterval(clockTimer);
|
||||||
|
clockTimer = null;
|
||||||
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||||
appStore.offServerRecovered(onRecovered);
|
appStore.offServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
@@ -126,47 +242,408 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">我的课表</h2>
|
<h2 class="page-title">我的课表</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
|
第 {{ week }} 周 · 今天 {{ todayItems.length }} 节 · 本周共 {{ items.length }} 节 ·
|
||||||
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
{{ courseCount }} 门课程
|
||||||
|
<el-tag v-if="fromCache" class="ml-8" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
|
<el-select v-model="week" style="width: 118px" @change="loadSchedules">
|
||||||
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
|
|
||||||
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
|
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)" />
|
||||||
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
|
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
|
||||||
下一周
|
<el-icon><ArrowRight /></el-icon>
|
||||||
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never">
|
<el-tabs v-model="activeTab" class="schedule-tabs">
|
||||||
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
|
<!-- ---------------------------------------------------- 今天(时间轴) -->
|
||||||
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
|
<el-tab-pane label="今天" name="today">
|
||||||
<el-table-column label="节次" width="110" fixed>
|
<div class="hero">
|
||||||
<template #default="{ row }">
|
<div class="hero-clock">
|
||||||
<div>{{ row.start }}</div>
|
<span class="clock-main">{{ clockText }}</span>
|
||||||
<div class="timetable-meta">{{ row.end }}</div>
|
<span class="clock-seconds">{{ clockSeconds }}</span>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
<div class="hero-meta">
|
||||||
<el-table-column
|
<div class="hero-date">{{ dateText }} · {{ weekdayText }}</div>
|
||||||
v-for="day in WEEKDAYS"
|
<div class="hero-week">第 {{ week }} 教学周{{ isCurrentWeek ? '' : '(查看中)' }}</div>
|
||||||
:key="day"
|
</div>
|
||||||
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
|
<div class="hero-status" :class="`status-${headline.kind}`">
|
||||||
min-width="132"
|
<div class="status-title">{{ headline.title }}</div>
|
||||||
>
|
<div class="status-detail">{{ headline.detail }}</div>
|
||||||
<template #default="{ row }">
|
<div v-if="headline.kind !== 'idle'" class="status-remain">
|
||||||
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
|
{{ headline.kind === 'current' ? '距下课' : '距上课' }} {{ remainText(headline.remain) }}
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
</div>
|
||||||
</el-table>
|
|
||||||
</el-card>
|
<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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -99,6 +99,8 @@ declare global {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
title: string;
|
title: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
|
||||||
|
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||