feat(desktop-client): 课表改为 ClassIsland 风格今天时间轴(大时钟 + 当前课强调色高亮 + 进度条与倒计时 + 已结束/下一节标记),补充时间轴自动化校验与实拍
This commit is contained in:
1 parent
219f6ae83a
commit
6b8563d0d3
10 files changed
+683
-50
No files matched your search
@@ -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
@@ -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>
|
||||
@@ -99,6 +99,8 @@ declare global {
|
||||
unreadCount: number;
|
||||
title: string;
|
||||
}>;
|
||||
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
|
||||
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user