feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)

This commit is contained in:
LoveChengke committed 2026-09-11 22:46:31 +08:00
1 parent 4566d7cd5e
commit 6e4c7c1050
42 files changed
+5767 -60

No files matched your search

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const booting = ref(true);
const bootText = ref('正在读取本地配置...');
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
* 2) 先用缓存资料渲染,再尝试向服务器刷新(不可达则进入离线会话)
* 3) 已登录则建立 Socket.IO 连接并进入主界面
*/
onMounted(async () => {
try {
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
booting.value = false;
}
});
</script>
<template>
<div v-if="booting" class="boot-screen">
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
<p class="boot-title">班级小助手</p>
<p class="boot-text">{{ bootText }}</p>
</div>
<router-view v-else />
</template>
<style scoped>
.boot-screen {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 60%, #409eff 100%);
color: #fff;
}
.boot-title {
margin: 6px 0 0;
font-size: 20px;
font-weight: 600;
}
.boot-text {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
</style>
@@ -0,0 +1,95 @@
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { ElMessage } from 'element-plus';
import type { ApiErrorResponse, ApiResponse } from '@classhelper/shared';
import { apiBaseOf } from '../config.js';
export const http = axios.create({ timeout: 15000 });
/** 服务器地址变化时更新 baseURL(登录页/设置页可改) */
export function setApiBaseUrl(serverUrl: string): void {
http.defaults.baseURL = apiBaseOf(serverUrl);
}
let tokenProvider: () => string | null = () => null;
export function setTokenProvider(provider: () => string | null): void {
tokenProvider = provider;
}
let unauthorizedHandler: () => void = () => undefined;
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler;
}
let reachabilityReporter: (reachable: boolean) => void = () => undefined;
export function setReachabilityReporter(reporter: (reachable: boolean) => void): void {
reachabilityReporter = reporter;
}
/** 判断是否为「网络不可达」(用于离线判定,不弹提示) */
export function isNetworkError(error: unknown): boolean {
const axiosError = error as AxiosError | undefined;
return Boolean(axiosError && !axiosError.response);
}
http.interceptors.request.use((config) => {
const token = tokenProvider();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
(response) => {
reachabilityReporter(true);
const body = response.data as ApiResponse<unknown> | undefined;
if (body && typeof body === 'object' && 'success' in body) {
if (!body.success) {
ElMessage.error(body.message || '请求失败');
return Promise.reject(new Error(body.message || '请求失败'));
}
return body.data as never;
}
return response.data as never;
},
(error: AxiosError<ApiErrorResponse>) => {
// 网络不可达:交给调用方走离线缓存,不打扰用户
if (!error.response) {
reachabilityReporter(false);
return Promise.reject(error);
}
reachabilityReporter(true);
const status = error.response.status;
const message = error.response.data?.message ?? `请求失败(${status})`;
if (status === 401) {
ElMessage.warning('登录状态已失效,请重新登录');
unauthorizedHandler();
} else {
ElMessage.error(message);
}
return Promise.reject(error);
},
);
async function request<T>(config: AxiosRequestConfig): Promise<T> {
return (await http.request(config)) as unknown as T;
}
export const api = {
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
request<T>({ method: 'GET', url, params }),
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
};
/** 健康检查(用于可达性轮询,不弹任何提示) */
export async function pingHealth(): Promise<{ ok: boolean; status?: string }> {
try {
const base = http.defaults.baseURL ?? '';
const response = await axios.get(`${base}/health`, { timeout: 5000 });
return { ok: true, status: response.data?.data?.status as string | undefined };
} catch {
return { ok: false };
}
}
@@ -0,0 +1,61 @@
import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type DashboardSummary,
type GradeDto,
type HomeworkDto,
type HomeworkStatusDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
type ScheduleDto,
type ScheduleWeekView,
type StudentDto,
} from '@classhelper/shared';
import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = {
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
};
export const classApi = {
list: (): Promise<ClassDto[]> => api.get(API_PATHS.classes),
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
};
export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
api.get(`${API_PATHS.schedules}/grid`, params),
};
export const homeworkApi = {
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
api.get(API_PATHS.homeworks, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
};
export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
api.post(`${API_PATHS.notifications}/${id}/read`, {}),
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
};
export const gradeApi = {
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
};
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
};
+133
View File
@@ -0,0 +1,133 @@
/**
* IndexedDB 缓存层(无第三方依赖)。
*
* 设计:
* - 一个数据库 classhelper-cache,按业务域拆分为多个 object store
* - 每条记录 = { key, value, updatedAt },key 形如 `${classId}` / `${classId}:${week}`
* - 所有操作都包成 Promise;任何一步失败都不会抛出到 UI(离线时降级为空)
*/
export const CACHE_STORES = [
'profile',
'classes',
'schedules',
'homeworks',
'notifications',
'grades',
] as const;
export type CacheStore = (typeof CACHE_STORES)[number];
export interface CacheRecord<T> {
key: string;
value: T;
updatedAt: number;
}
export interface CacheStoreStat {
store: CacheStore;
count: number;
updatedAt: number | null;
}
const DB_NAME = 'classhelper-cache';
const DB_VERSION = 1;
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise;
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
for (const store of CACHE_STORES) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'key' });
}
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败'));
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用'));
});
return databasePromise;
}
function runRequest<T>(
store: CacheStore,
mode: IDBTransactionMode,
action: (objectStore: IDBObjectStore) => IDBRequest,
): Promise<T> {
return openDatabase().then(
(db) =>
new Promise<T>((resolve, reject) => {
const transaction = db.transaction(store, mode);
const request = action(transaction.objectStore(store));
request.onsuccess = () => resolve(request.result as T);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
}),
);
}
/**
* 转成可被 IndexedDB 结构化克隆的纯数据。
*
* 关键:Vue 的 reactive/ref 包装对象是 Proxy,直接 put 会抛
* "could not be cloned"。缓存内容都是后端 JSON 响应,因此用 JSON 往返即可,
* 既去掉代理又保证与 DTO(日期为 ISO 字符串)一致。
*/
function toPlain<T>(value: T): T {
if (value === undefined) return null as unknown as T;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
export async function cacheSet<T>(store: CacheStore, key: string, value: T): Promise<void> {
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() };
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
}
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> {
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(key),
).then((record) => record ?? null);
}
export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>> {
return runRequest<Array<CacheRecord<T>>>(store, 'readonly', (objectStore) => objectStore.getAll());
}
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key));
}
export async function cacheClearStore(store: CacheStore): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.clear());
}
export async function cacheClearAll(): Promise<void> {
for (const store of CACHE_STORES) {
await cacheClearStore(store);
}
}
/** 各业务域的条目数与最近更新时间,用于「设置」页展示离线缓存状况 */
export async function cacheStats(): Promise<CacheStoreStat[]> {
const stats: CacheStoreStat[] = [];
for (const store of CACHE_STORES) {
const records = await cacheList<unknown>(store);
const latest = records.reduce<number | null>(
(max, record) => (max === null || record.updatedAt > max ? record.updatedAt : max),
null,
);
stats.push({ store, count: records.length, updatedAt: latest });
}
return stats;
}
+54
View File
@@ -0,0 +1,54 @@
import { cacheGet, cacheSet, type CacheStore } from './db.js';
export interface CachedFetchResult<T> {
data: T;
/** true 表示本次数据来自本地缓存(网络失败或未联网) */
fromCache: boolean;
/** 缓存写入时间(来自缓存时有效) */
updatedAt: number | null;
/** 网络失败原因(成功时为 null) */
error: string | null;
}
/**
* 带离线回退的取数:
* 1) 先请求网络,成功则写入缓存并返回
* 2) 网络失败则读取缓存;缓存缺失时返回调用方提供的兜底值
*
* 「联网后自动同步」由调用方在重连事件里再次调用本函数完成。
*/
export async function fetchWithCache<T>(
store: CacheStore,
key: string,
loader: () => Promise<T>,
fallback: T,
): Promise<CachedFetchResult<T>> {
try {
const data = await loader();
await cacheSet(store, key, data);
return { data, fromCache: false, updatedAt: Date.now(), error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) {
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
}
return { data: fallback, fromCache: false, updatedAt: null, error: message };
}
}
/** 只读缓存(不再发请求),用于启动时先渲染旧数据 */
export async function readCache<T>(
store: CacheStore,
key: string,
fallback: T,
): Promise<CachedFetchResult<T>> {
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: null };
return { data: fallback, fromCache: false, updatedAt: null, error: null };
}
/** 把实时推送的数据直接写入缓存,保证离线时看到的是最新内容 */
export async function writeCache<T>(store: CacheStore, key: string, value: T): Promise<void> {
await cacheSet(store, key, value);
}
+152
View File
@@ -0,0 +1,152 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js';
export interface SmokeCheckResult {
ok: boolean;
detail: string;
}
/**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。
*/
export async function cacheSelfTest(): Promise<SmokeCheckResult> {
const key = '__smoke_selftest__';
const payload = { text: 'ok', at: Date.now() };
await cacheSet('profile', key, payload);
const readBack = await cacheGet<typeof payload>('profile', key);
const stats = await cacheStats();
await cacheRemove('profile', key);
const removed = await cacheGet<typeof payload>('profile', key);
const ok = readBack?.value.text === 'ok' && removed === null && stats.length > 0;
return {
ok,
detail: `readBack=${readBack?.value.text ?? '-'} removed=${removed === null} stores=${stats.length}`,
};
}
/**
* 冒烟自检 2:断网回退。
* 先写入缓存,再向一个不存在的端口发请求(必然失败),
* 验证 fetchWithCache 能回退到本地缓存。
*/
export async function offlineScenario(): Promise<SmokeCheckResult> {
const key = '__smoke_offline__';
const payload = [{ id: 'offline-1', title: '离线缓存中的作业' }];
await cacheSet('homeworks', key, payload);
const result = await fetchWithCache<Array<{ id: string; title: string }>>(
'homeworks',
key,
async () => {
const response = await fetch('http://127.0.0.1:59999/api/homeworks');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Array<{ id: string; title: string }>;
},
[],
);
await cacheRemove('homeworks', key);
const ok = result.fromCache === true && result.data.length === 1 && Boolean(result.error);
return {
ok,
detail: `fromCache=${result.fromCache} items=${result.data.length} error=${result.error ?? '-'}`,
};
}
/**
* 冒烟自检 3(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
*/
export async function onlineScenario(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'),
import('../stores/app.js'),
import('../stores/realtime.js'),
import('../api/index.js'),
import('./index.js'),
]);
const auth = useAuthStore();
const appStore = useAppStore();
const realtime = useRealtimeStore();
const serverUrl = 'http://127.0.0.1:4000';
const detail: string[] = [];
try {
await auth.login(serverUrl, 'student01', 'student123');
detail.push(`login=${auth.user?.name ?? '-'}`);
await appStore.init(serverUrl);
detail.push(`reachable=${appStore.serverReachable}`);
const [classes, homeworks, notifications, grades] = await Promise.all([
api.classApi.list(),
api.homeworkApi.list(),
api.notificationApi.list(),
api.gradeApi.my(),
]);
const term = await api.dashboardApi.term();
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
detail.push(`classes=${classes.length}`);
detail.push(`homeworks=${homeworks.length}`);
detail.push(`notifications=${notifications.length}`);
detail.push(`grades=${grades.length}`);
detail.push(`week${term.currentWeek}Schedules=${schedules.length}`);
// 写入缓存(离线可用的前提)
await cache.writeCache('homeworks', 'self', homeworks);
await cache.writeCache('grades', 'my', grades);
await cache.writeCache('notifications', 'self', notifications);
// Socket.IO:连接并等待服务端回执
if (auth.token) {
realtime.connect(serverUrl, auth.token);
const connected = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 8000);
const handler = (): void => {
clearTimeout(timer);
resolve(true);
};
realtime.on('__connected__', handler);
if (realtime.connected) handler();
});
detail.push(`socket=${connected}`);
if (!connected) {
realtime.disconnect();
await auth.logout();
return { ok: false, detail: `${detail.join(' ')} (实时通道未连接)` };
}
}
const ok =
classes.length === 1 &&
schedules.length > 0 &&
homeworks.length > 0 &&
notifications.length > 0 &&
grades.length > 0 &&
realtime.connected;
realtime.disconnect();
await auth.logout(); // 清理登录态,保证下次冒烟从登录页开始
return { ok, detail: detail.join(' ') };
} catch (error) {
realtime.disconnect();
await auth.logout().catch(() => undefined);
return {
ok: false,
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = { cacheSelfTest, offlineScenario, onlineScenario };
}
@@ -0,0 +1,26 @@
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/** 默认后端地址(首次启动的预填值) */
export const DEFAULT_SERVER = DEFAULT_SERVER_URL;
/** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */
export function apiBaseOf(serverUrl: string): string {
return `${serverUrl.trim().replace(/\/+$/, '')}/api`;
}
/** 由服务器地址推导 Socket.IO 地址 */
export function socketUrlOf(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
/** 校验用户输入的服务器地址 */
export function normalizeServerUrl(input: string): string | null {
const value = input.trim().replace(/\/+$/, '');
if (!/^https?:\/\/[^\s]+$/i.test(value)) return null;
try {
const url = new URL(value);
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const menuItems = [
{ name: 'schedule', title: '课表', icon: 'Calendar' },
{ name: 'homeworks', title: '作业', icon: 'Notebook' },
{ name: 'notifications', title: '通知', icon: 'Bell' },
{ name: 'grades', title: '成绩', icon: 'Trophy' },
{ name: 'settings', title: '设置', icon: 'Setting' },
];
const activeMenu = computed(() => `/${String(route.name ?? 'schedule')}`);
const connectionType = computed(() => {
if (realtime.connected) return 'success';
if (appStore.serverReachable) return 'warning';
return 'danger';
});
const connectionText = computed(() => {
if (realtime.connected) return '实时连接正常';
if (appStore.serverReachable) return '服务器可达(实时通道重连中)';
return '离线模式 · 显示缓存数据';
});
function go(name: string): void {
void router.push({ name });
}
async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
void router.push({ name: 'login' });
}
/** 通知实时推送:更新列表 + 红点 + 缓存 */
function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
function onRecovered(): void {
ElMessage.success('已重新连接服务器,正在同步最新数据');
void notifications.load();
}
onMounted(async () => {
await notifications.load().catch(() => undefined);
await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<el-container class="layout">
<el-aside width="190px" class="aside">
<div class="brand">
<el-icon :size="20"><School /></el-icon>
<span>班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="go">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
<el-badge
v-if="item.name === 'notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
class="menu-badge"
/>
</el-menu-item>
</el-menu>
<div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-tag :type="connectionType" size="small" effect="light">{{ connectionText }}</el-tag>
<el-tag v-if="auth.offlineSession" type="warning" size="small" effect="plain">离线会话</el-tag>
<span class="server-url">{{ appStore.serverUrl }}</span>
</div>
<div class="header-right">
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
{{ auth.displayName }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="go('settings')">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<el-alert
v-if="appStore.offline && appStore.initialized"
class="offline-banner"
type="warning"
:closable="false"
show-icon
title="当前处于离线状态"
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
/>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout {
height: 100vh;
}
.aside {
background: var(--ch-sidebar);
color: #fff;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 54px;
padding: 0 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.menu {
flex: 1;
border-right: none;
background: transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #fff;
}
.menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.menu :deep(.el-menu-item.is-active) {
background: #409eff;
}
.menu-badge {
margin-left: 10px;
}
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.aside-meta {
font-size: 12px;
color: #8fa3b8;
line-height: 1.8;
}
.header {
background: #fff;
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.server-url {
font-size: 12px;
color: #909399;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
outline: none;
}
.main {
background: var(--ch-bg);
padding: 0;
overflow-y: auto;
}
</style>
@@ -0,0 +1,29 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js';
import './styles/index.css';
const app = createApp(App);
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)
if (window.desktop?.smokeTest) {
registerSmokeHooks();
}
await router.isReady();
app.mount('#app');
@@ -0,0 +1,70 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.js';
/**
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
* 因此统一使用 hash 路由(开发与打包行为一致)。
*/
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { public: true, title: '登录' },
},
{
path: '/',
component: () => import('../layouts/ClientLayout.vue'),
redirect: '/schedule',
children: [
{
path: 'schedule',
name: 'schedule',
component: () => import('../views/ScheduleView.vue'),
meta: { title: '课表' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('../views/HomeworkView.vue'),
meta: { title: '作业' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('../views/NotificationView.vue'),
meta: { title: '通知' },
},
{
path: 'grades',
name: 'grades',
component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' },
},
{
path: 'settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: '设置' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
];
export const router = createRouter({
history: createWebHashHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (to.name === 'login' && auth.isAuthenticated) return { name: 'schedule' };
return true;
}
if (!auth.isAuthenticated) return { name: 'login' };
return true;
});
export default router;
@@ -0,0 +1,132 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
type RecoveryHandler = () => void;
/**
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
*/
export const useAppStore = defineStore('app', () => {
const serverUrl = ref(DEFAULT_SERVER);
const serverReachable = ref(false);
const lastSyncAt = ref<number | null>(null);
const cacheStoreStats = ref<CacheStoreStat[]>([]);
const currentWeek = ref(1);
const maxWeek = ref(20);
const initialized = ref(false);
const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
const cacheItemCount = computed(() => cacheStoreStats.value.reduce((sum, item) => sum + item.count, 0));
const recoveryHandlers = new Set<RecoveryHandler>();
let healthTimer: ReturnType<typeof setInterval> | null = null;
function onServerRecovered(handler: RecoveryHandler): void {
recoveryHandlers.add(handler);
}
function offServerRecovered(handler: RecoveryHandler): void {
recoveryHandlers.delete(handler);
}
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
setReachabilityReporter((reachable) => {
if (reachable && !serverReachable.value && initialized.value) {
serverReachable.value = true;
notifyRecovered();
return;
}
serverReachable.value = reachable;
});
function notifyRecovered(): void {
for (const handler of recoveryHandlers) handler();
}
async function ping(): Promise<void> {
const result = await pingHealth();
if (result.ok) {
if (!serverReachable.value && initialized.value) notifyRecovered();
serverReachable.value = true;
} else {
serverReachable.value = false;
}
}
function startHealthPolling(): void {
if (healthTimer) return;
healthTimer = setInterval(() => {
void ping();
}, 20000);
}
function stopHealthPolling(): void {
if (!healthTimer) return;
clearInterval(healthTimer);
healthTimer = null;
}
async function applyServerUrl(next: string): Promise<boolean> {
const normalized = normalizeServerUrl(next);
if (!normalized) return false;
serverUrl.value = normalized;
setApiBaseUrl(normalized);
localStorage.setItem(STORAGE_KEYS.serverUrl, normalized);
if (window.desktop) await window.desktop.saveConfig({ serverUrl: normalized });
return true;
}
async function refreshCacheStats(): Promise<void> {
try {
cacheStoreStats.value = await cacheStats();
} catch {
cacheStoreStats.value = [];
}
}
async function clearCache(): Promise<void> {
await cacheClearAll();
await refreshCacheStats();
}
async function init(serverFromConfig: string): Promise<void> {
if (serverFromConfig) await applyServerUrl(serverFromConfig);
await refreshCacheStats();
await ping();
startHealthPolling();
initialized.value = true;
}
function markSynced(): void {
lastSyncAt.value = Date.now();
void refreshCacheStats();
}
return {
serverUrl,
serverReachable,
offline,
lastSyncAt,
lastSyncText,
cacheStoreStats,
cacheItemCount,
currentWeek,
maxWeek,
initialized,
init,
ping,
applyServerUrl,
refreshCacheStats,
clearCache,
markSynced,
onServerRecovered,
offServerRecovered,
stopHealthPolling,
};
});
@@ -0,0 +1,131 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared';
import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js';
import { normalizeServerUrl } from '../config.js';
const PROFILE_KEY = 'me';
/**
* 登录态。
* token 通过 IPC 交给主进程用 safeStorage 加密落盘,刷新/重启后可直接离线进入;
* 用户资料同时写入 IndexedDB,断网时也能显示姓名与班级。
*/
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null);
const user = ref<StudentDto | null>(null);
const loading = ref(false);
const offlineSession = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const displayName = computed(() => user.value?.name ?? '未登录');
const classId = computed(() => user.value?.classId ?? null);
setTokenProvider(() => token.value);
function applyToken(next: string | null): void {
token.value = next;
setTokenProvider(() => token.value);
}
async function persistSession(
serverUrl: string,
username: string,
nextToken: string | null,
): Promise<void> {
if (!window.desktop) return;
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
}
/** 登录:写主进程配置 + 缓存资料 */
async function login(serverUrl: string, username: string, password: string): Promise<void> {
const normalized = normalizeServerUrl(serverUrl);
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
setApiBaseUrl(normalized);
loading.value = true;
try {
const payload: LoginResponse = await authApi.login({ username, password });
applyToken(payload.token);
user.value = payload.user as StudentDto;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value);
await persistSession(normalized, username, payload.token);
} finally {
loading.value = false;
}
}
/**
* 启动恢复:先用缓存资料立即渲染,再尝试向服务器刷新。
* 服务器不可达时保持离线会话(可查看缓存数据)。
*/
async function restore(): Promise<{ serverUrl: string; username: string }> {
let serverUrl = '';
let username = '';
if (window.desktop) {
const config = await window.desktop.getConfig();
serverUrl = config.serverUrl;
username = config.username;
applyToken(config.token);
}
if (serverUrl) setApiBaseUrl(serverUrl);
if (!serverUrl) {
const fallback = localStorage.getItem(STORAGE_KEYS.serverUrl);
if (fallback) {
serverUrl = fallback;
setApiBaseUrl(fallback);
}
}
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value;
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
if (token.value) void refreshProfile();
return { serverUrl, username };
}
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
async function refreshProfile(): Promise<void> {
if (!token.value) return;
try {
const profile = await authApi.me();
user.value = profile;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, profile);
} catch {
offlineSession.value = Boolean(user.value);
}
}
async function logout(): Promise<void> {
try {
if (token.value) await authApi.logout();
} catch {
// 离线退出忽略错误
}
applyToken(null);
user.value = null;
offlineSession.value = false;
if (window.desktop) await window.desktop.saveConfig({ token: null });
}
return {
token,
user,
loading,
offlineSession,
isAuthenticated,
displayName,
classId,
login,
restore,
refreshProfile,
logout,
applyToken,
};
});
@@ -0,0 +1,107 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import type { NotificationDto } from '@classhelper/shared';
import { notificationApi } from '../api/index.js';
import { cacheSet } from '../cache/db.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from './app.js';
/**
* 通知状态:列表 + 未读数。
* 抽成 store 是因为侧边栏红点与通知页共用同一份数据,
* 同时所有变更都会同步写回 IndexedDB,保证离线可见。
*/
export const useNotificationStore = defineStore('notifications', () => {
const items = ref<NotificationDto[]>([]);
const unreadCount = ref(0);
const loading = ref(false);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const error = ref<string | null>(null);
const unreadItems = computed(() => items.value.filter((item) => !item.read));
function currentKey(): string {
return 'self';
}
async function persist(): Promise<void> {
await cacheSet('notifications', currentKey(), items.value).catch(() => undefined);
}
function recount(): void {
unreadCount.value = items.value.filter((item) => !item.read).length;
}
async function load(): Promise<void> {
loading.value = true;
try {
const result = await fetchWithCache<NotificationDto[]>(
'notifications',
currentKey(),
() => notificationApi.list(),
[],
);
items.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
error.value = result.error;
recount();
if (!result.fromCache) useAppStore().markSynced();
} finally {
loading.value = false;
}
}
/** 实时推送:插入到列表头部并写回缓存 */
async function pushRealtime(payload: NotificationDto): Promise<void> {
if (items.value.some((item) => item.id === payload.id)) return;
items.value = [{ ...payload, read: false }, ...items.value];
recount();
await persist();
}
async function markRead(id: string): Promise<void> {
await notificationApi.markRead(id);
const target = items.value.find((item) => item.id === id);
if (target) {
target.read = true;
target.readAt = new Date().toISOString();
}
recount();
await persist();
}
async function markAllRead(): Promise<number> {
const result = await notificationApi.markAllRead();
const now = new Date().toISOString();
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
recount();
await persist();
return result.marked;
}
async function refreshUnreadCount(): Promise<void> {
try {
const result = await notificationApi.unreadCount();
unreadCount.value = result.count;
} catch {
// 离线时保留缓存推算出的未读数
}
}
return {
items,
unreadItems,
unreadCount,
loading,
fromCache,
updatedAt,
error,
load,
pushRealtime,
markRead,
markAllRead,
refreshUnreadCount,
};
});
@@ -0,0 +1,148 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { ElMessage } from 'element-plus';
import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type GradeDto,
type HomeworkDto,
type NotificationDto,
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
/**
* 实时通道(学生端)。
* 与 Web 管理端使用同一套事件名;收到事件后除了通知用户,
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/
export const useRealtimeStore = defineStore('realtime', () => {
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
const lastEventAt = ref<number | null>(null);
const eventCount = ref(0);
const handlers = new Map<string, Set<EventHandler>>();
let socket: Socket | null = null;
const statusText = computed(() => {
if (connected.value) return '实时连接正常';
if (connecting.value) return '正在连接服务器';
return '实时连接已断开';
});
function dispatch(event: string, payload: unknown): void {
for (const handler of handlers.get(event) ?? []) handler(payload);
}
function mark(event: string): void {
lastEventName.value = event;
lastEventAt.value = Date.now();
eventCount.value += 1;
}
function bind(instance: Socket): void {
instance.on('connect', () => {
connected.value = true;
connecting.value = false;
dispatch('__connected__', null);
});
instance.on('disconnect', () => {
connected.value = false;
dispatch('__disconnected__', null);
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
connecting.value = false;
dispatch('__connect_error__', error.message);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew);
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
});
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
mark(SOCKET_EVENTS.gradeUpdated);
ElMessage.success(`成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`);
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
});
instance.on(SOCKET_EVENTS.scheduleUpdated, (payload: unknown) => {
mark(SOCKET_EVENTS.scheduleUpdated);
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
});
}
function connect(serverUrl: string, token: string): void {
disconnect();
connecting.value = true;
socket = io(socketUrlOf(serverUrl), {
auth: { token, clientType: 'desktop' },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1500,
reconnectionDelayMax: 10000,
});
bind(socket);
}
function disconnect(): void {
socket?.removeAllListeners();
socket?.disconnect();
socket = null;
connected.value = false;
connecting.value = false;
}
function on(
event: RealtimeEventName | '__connected__' | '__disconnected__' | '__connect_error__',
handler: EventHandler,
): void {
const set = handlers.get(event) ?? new Set<EventHandler>();
set.add(handler);
handlers.set(event, set);
}
function off(
event: RealtimeEventName | '__connected__' | '__disconnected__' | '__connect_error__',
handler: EventHandler,
): void {
handlers.get(event)?.delete(handler);
}
return {
connected,
connecting,
statusText,
lastEventName,
lastEventAt,
eventCount,
connect,
disconnect,
on,
off,
};
});
@@ -0,0 +1,115 @@
:root {
--ch-bg: #f5f7fa;
--ch-border: #e4e7ed;
--ch-sidebar: #1f2d3d;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--ch-bg);
color: #303133;
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
}
input,
textarea {
user-select: text;
}
.page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.text-muted {
color: #909399;
}
.offline-banner {
margin: 0 0 12px;
}
.timetable-cell {
min-height: 30px;
}
.timetable-course {
font-weight: 600;
}
.timetable-meta {
font-size: 12px;
color: #909399;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
margin-right: 6px;
}
/* 滚动条美化(Windows 默认滚动条较粗) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #c8ccd4;
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
averageOf,
formatDate,
gradeLevel,
gradePercent,
type GradeDto,
} from '@classhelper/shared';
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const grades = ref<GradeDto[]>([]);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const chartRef = ref<HTMLDivElement>();
let chart: ReturnType<typeof echarts.init> | null = null;
const averagePercent = computed(() =>
grades.value.length === 0
? 0
: averageOf(grades.value.map((item) => gradePercent(item.score, item.totalScore))),
);
const bestGrade = computed(() => {
if (grades.value.length === 0) return null;
return grades.value.reduce((best, item) =>
gradePercent(item.score, item.totalScore) > gradePercent(best.score, best.totalScore) ? item : best,
);
});
const latestGrades = computed(() =>
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
);
const levelCounts = computed(() => {
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 };
for (const item of grades.value) {
levels[gradeLevel(gradePercent(item.score, item.totalScore))] =
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1;
}
return levels;
});
async function loadGrades(): Promise<void> {
loading.value = true;
try {
const result = await fetchWithCache<GradeDto[]>('grades', 'my', () => gradeApi.my(), []);
grades.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
renderChart();
} finally {
loading.value = false;
}
}
function renderChart(): void {
if (!chartRef.value) return;
chart ??= echarts.init(chartRef.value);
// 按考试聚合平均得分率(最近 8 次考试)
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
const exams = [...byExam.entries()].slice(-8);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 44, right: 18, top: 30, bottom: 34 },
xAxis: {
type: 'category',
data: exams.map(([name]) => name),
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
},
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'bar',
barMaxWidth: 46,
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
data: exams.map(([, percents]) => averageOf(percents)),
},
],
});
}
function handleResize(): void {
chart?.resize();
}
function onGradeEvent(): void {
void loadGrades();
}
function onRecovered(): void {
void loadGrades();
}
onMounted(async () => {
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
chart?.dispose();
chart = null;
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的成绩</h2>
<p class="page-subtitle">
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="8">
<div class="stat-card">
<div class="stat-label">平均得分率</div>
<div class="stat-value">{{ averagePercent }}%</div>
<div class="stat-hint">共 {{ grades.length }} 次记录</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">最好成绩</div>
<div class="stat-value">
{{ bestGrade ? `${bestGrade.score}/${bestGrade.totalScore}` : '-' }}
</div>
<div class="stat-hint">{{ bestGrade?.examName ?? '暂无数据' }}</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">等级分布</div>
<div class="level-list">
<el-tag v-for="(count, level) in levelCounts" :key="level" effect="plain">
{{ level }} 等:{{ count }}
</el-tag>
</div>
</div>
</el-col>
<el-col :xs="24" :md="16">
<el-card v-loading="loading" shadow="never">
<template #header><span>各次考试平均得分率</span></template>
<div ref="chartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<template #header><span>成绩明细</span></template>
<el-table :data="grades" empty-text="暂无成绩" max-height="420">
<el-table-column prop="examName" label="考试" min-width="150" />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="分数" width="130">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
<el-table-column label="得分率" width="200">
<template #default="{ row }">
<el-progress
:percentage="gradePercent(row.score, row.totalScore)"
:stroke-width="10"
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
/>
</template>
</el-table-column>
<el-table-column label="等级" width="80">
<template #default="{ row }">
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="latestGrades.length > 0" shadow="never" class="mt-16">
<template #header><span>最近更新</span></template>
<el-timeline>
<el-timeline-item
v-for="item in latestGrades"
:key="item.id"
:timestamp="formatDate(item.publishedAt, true)"
placement="top"
>
{{ item.examName }} · {{ item.course?.name ?? '未关联课程' }}:
<strong>{{ item.score }}/{{ item.totalScore }}</strong>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
<style scoped>
.stat-card {
background: #fff;
border: 1px solid var(--ch-border);
border-radius: 10px;
padding: 16px;
}
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
margin-top: 6px;
font-size: 26px;
font-weight: 700;
}
.stat-hint {
margin-top: 4px;
font-size: 12px;
color: #c0c4cc;
}
.level-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chart {
width: 100%;
height: 300px;
}
</style>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
const filter = ref<'all' | 'pending' | 'done'>('all');
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const submitting = ref(false);
const filtered = computed(() => {
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
return homeworks.value;
});
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
async function loadHomework(): Promise<void> {
loading.value = true;
try {
const classId = auth.classId ?? undefined;
const result = await fetchWithCache<HomeworkDto[]>(
'homeworks',
'self',
() => homeworkApi.list(classId ? { classId } : undefined),
[],
);
homeworks.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
if (current.value) {
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
}
} finally {
loading.value = false;
}
}
function openDetail(item: HomeworkDto): void {
current.value = item;
detailVisible.value = true;
}
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
if (!item) return;
if (appStore.offline) {
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
return;
}
submitting.value = true;
try {
const next = item.completed !== true;
await homeworkApi.updateStatus(item.id, next);
item.completed = next;
if (item.homeworkStatus) item.homeworkStatus.completed = next;
else
item.homeworkStatus = {
id: `local-${item.id}`,
homeworkId: item.id,
userId: auth.user?.id ?? '',
completed: next,
updatedAt: new Date().toISOString(),
};
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败');
} finally {
submitting.value = false;
}
}
function onHomeworkEvent(): void {
void loadHomework();
}
/** 附件链接交给系统浏览器打开(模板里无法直接访问 window) */
function openAttachment(url: string | null | undefined): void {
if (!url) return;
if (window.desktop) void window.desktop.openExternal(url);
else window.open(url, '_blank');
}
function onRecovered(): void {
void loadHomework();
}
onMounted(async () => {
await loadHomework();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的作业</h2>
<p class="page-subtitle">
共 {{ homeworks.length }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="pending">未完成</el-radio-button>
<el-radio-button value="done">已完成</el-radio-button>
</el-radio-group>
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
size="small"
effect="light"
>
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="作业标题" min-width="220" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="剩余" width="120">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button
link
:type="row.completed ? 'warning' : 'primary'"
:loading="submitting"
@click.stop="toggleComplete(row)"
>
{{ row.completed ? '取消完成' : '标记完成' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
<template v-if="current">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
<el-descriptions-item label="我的状态" :span="2">
<el-tag :type="current.completed ? 'success' : 'info'" size="small">
{{ current.completed ? '已完成' : '未完成' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
<h4>作业要求</h4>
<p class="content-block">{{ current.content }}</p>
</div>
<div v-if="current.attachmentUrl" class="mt-12">
<el-button link type="primary" @click="openAttachment(current.attachmentUrl)">
打开附件链接
</el-button>
</div>
<div class="mt-16">
<el-button
type="primary"
:loading="submitting"
:disabled="appStore.offline"
@click="toggleComplete(current)"
>
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
</el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.text-danger {
color: #f56c6c;
}
.content-block {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
border-radius: 8px;
padding: 12px;
margin: 0;
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null);
const rules: FormRules = {
serverUrl: [
{
validator: (_rule, value: string, callback: (error?: Error) => void) => {
if (!value?.trim()) callback(new Error('请输入服务器地址'));
else if (!normalizeServerUrl(value)) callback(new Error('地址格式应为 http://主机:端口'));
else callback();
},
trigger: 'blur',
},
],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
onMounted(async () => {
// 主进程已保存过服务器地址/上次用户名则预填
if (window.desktop) {
const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.username = config.username;
}
});
async function testConnection(): Promise<void> {
const normalized = normalizeServerUrl(form.serverUrl);
if (!normalized) {
ElMessage.warning('请先填写正确的服务器地址');
return;
}
testing.value = true;
testResult.value = null;
try {
setApiBaseUrl(normalized);
const result = await pingHealth();
testResult.value = result.ok ? 'ok' : 'fail';
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
try {
await auth.login(form.serverUrl, form.username.trim(), form.password);
await appStore.init(form.serverUrl);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`欢迎回来,${auth.displayName}`);
await router.replace('/schedule');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败');
}
}
/** 已保存登录态但服务器不可达时,允许用缓存数据离线进入 */
async function enterOffline(): Promise<void> {
if (!auth.isAuthenticated) return;
await appStore.init(appStore.serverUrl);
ElMessage.warning('已进入离线模式,仅显示上次同步的数据');
await router.replace('/schedule');
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-head">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="服务器地址" prop="serverUrl">
<el-input v-model="form.serverUrl" placeholder="http://127.0.0.1:4000" clearable>
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
show-password
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<div class="login-actions">
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div>
<el-alert
v-if="testResult === 'ok'"
class="mt-12"
type="success"
:closable="false"
title="服务器可达,可以登录"
/>
<el-alert
v-if="testResult === 'fail'"
class="mt-12"
type="error"
:closable="false"
title="无法连接服务器"
description="请确认后端已启动(pnpm dev:server)、地址与端口正确、防火墙未拦截。"
/>
</el-form>
<el-divider v-if="auth.isAuthenticated">或</el-divider>
<el-button v-if="auth.isAuthenticated" class="offline-button" @click="enterOffline">
<el-icon><Connection /></el-icon>
离线进入(使用本地缓存数据)
</el-button>
<p class="login-tip">
演示账号:student01 / student123(高一(1)班)
<br />
数据来源:教师或管理员在 Web 管理端发布的内容。
</p>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
}
.login-card {
width: 420px;
background: #fff;
border-radius: 14px;
padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
}
.login-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.login-title {
margin: 0;
font-size: 20px;
}
.login-sub {
margin: 4px 0 0;
font-size: 12px;
color: #909399;
}
.login-actions {
display: flex;
gap: 10px;
}
.login-actions .el-button {
flex: 1;
}
.offline-button {
width: 100%;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
formatDate,
relativeTime,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
import { useAppStore } from '../stores/app.js';
import { useNotificationStore } from '../stores/notifications.js';
const appStore = useAppStore();
const store = useNotificationStore();
const filter = ref<'all' | 'unread'>('all');
const detailVisible = ref(false);
const current = ref<NotificationDto | null>(null);
const marking = ref(false);
const visibleItems = computed(() =>
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
);
async function openDetail(item: NotificationDto): Promise<void> {
current.value = item;
detailVisible.value = true;
if (!item.read) await markRead(item);
}
async function markRead(item: NotificationDto): Promise<void> {
if (appStore.offline || item.read) return;
marking.value = true;
try {
await store.markRead(item.id);
if (current.value?.id === item.id) current.value = { ...item, read: true };
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '标记已读失败');
} finally {
marking.value = false;
}
}
async function markAll(): Promise<void> {
if (appStore.offline) {
ElMessage.warning('离线状态下无法同步已读状态');
return;
}
marking.value = true;
try {
const marked = await store.markAllRead();
ElMessage.success(`已将 ${marked} 条通知标为已读`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '操作失败');
} finally {
marking.value = false;
}
}
onMounted(async () => {
await store.load().catch(() => undefined);
await store.refreshUnreadCount();
});
onUnmounted(() => {
// store 是全局的,这里无需清理;保留钩子以便将来取消订阅
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通知</h2>
<p class="page-subtitle">
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="unread">未读</el-radio-button>
</el-radio-group>
<el-button :loading="marking" :disabled="store.unreadCount === 0" @click="markAll">
全部已读
</el-button>
<el-button :icon="'Refresh'" @click="store.load()">刷新</el-button>
</div>
</div>
<el-card v-loading="store.loading" shadow="never">
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
<ul v-else class="notice-list">
<li
v-for="item in visibleItems"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
<template v-if="current">
<div class="drawer-meta">
<el-tag :type="PRIORITY_TAG_TYPES[current.priority as NotificationPriority]" size="small">
{{ PRIORITY_LABELS[current.priority as NotificationPriority] }}
</el-tag>
<span class="text-muted">{{ formatDate(current.createdAt, true) }}</span>
<span class="text-muted">发布人:{{ current.creator?.name ?? '-' }}</span>
</div>
<p class="content-block">{{ current.content }}</p>
<el-alert
v-if="!current.read && appStore.offline"
type="warning"
:closable="false"
title="离线状态:已读状态将在恢复网络后同步"
/>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.notice-list {
list-style: none;
margin: 0;
padding: 0;
}
.notice-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px 6px;
border-bottom: 1px dashed var(--ch-border);
cursor: pointer;
border-radius: 6px;
}
.notice-item:hover {
background: #f7f9fc;
}
.notice-item.unread {
background: #f0f7ff;
}
.notice-item:last-child {
border-bottom: none;
}
.notice-main {
flex: 1;
min-width: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
font-weight: 600;
}
.notice-content {
margin-top: 6px;
font-size: 13px;
color: #606266;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notice-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 12px;
white-space: nowrap;
}
.drawer-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.content-block {
white-space: pre-wrap;
line-height: 1.8;
background: #fafafa;
border-radius: 8px;
padding: 14px;
margin: 0 0 16px;
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
buildWeekOptions,
formatWeekRange,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { dashboardApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const grid = ref<ScheduleWeekView | null>(null);
const items = ref<ScheduleDto[]>([]);
const week = ref(appStore.currentWeek);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const weekOptions = ref<number[]>(buildWeekOptions(20));
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
for (const item of items.value) {
const key = `${item.startTime}-${item.endTime}`;
const bucket = buckets.get(key) ?? [];
bucket.push(item);
buckets.set(key, bucket);
}
return [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, list]) => {
const [start, end] = key.split('-');
return { start: start ?? '', end: end ?? '', items: list };
});
});
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
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);
async function loadTerm(): Promise<void> {
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
currentWeek: 1,
maxWeek: 20,
});
appStore.currentWeek = result.data.currentWeek;
appStore.maxWeek = result.data.maxWeek;
weekOptions.value = buildWeekOptions(result.data.maxWeek);
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
}
async function loadSchedules(): Promise<void> {
const classId = auth.classId ?? undefined;
loading.value = true;
try {
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week.value}`,
() => scheduleApi.list({ classId, week: week.value }),
[],
);
items.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
grid.value = {
week: week.value,
columns: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
label: WEEKDAY_LABELS[dayOfWeek] ?? '',
items: result.data.filter((item) => item.dayOfWeek === dayOfWeek),
})),
};
if (!result.fromCache) appStore.markSynced();
} finally {
loading.value = false;
}
}
async function changeWeek(delta: number): Promise<void> {
const next = week.value + delta;
if (next < 1 || next > appStore.maxWeek) return;
week.value = next;
await loadSchedules();
}
function onScheduleEvent(): void {
void loadSchedules();
}
function onRecovered(): void {
void loadSchedules();
}
onMounted(async () => {
await loadTerm();
await loadSchedules();
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<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>
</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-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
</el-select>
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
下一周
<el-icon class="el-icon--right"><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>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate } from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function loadAppInfo(): Promise<void> {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo();
}
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
onMounted(async () => {
await loadAppInfo();
await appStore.refreshCacheStats();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">设置</h2>
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ auth.user?.username ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>客户端信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.ml-8 {
margin-left: 8px;
}
</style>