feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0

Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
  端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
  运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
  (保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
  `--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
  upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
  completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
  直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
  classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
  ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
  Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。

三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
  「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
  两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
  自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
  已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
  是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。

更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
  一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
  改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。

Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
  (原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
  viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
  并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。

版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
  docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
  有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
  shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
This commit is contained in:
LoveChengke committed 2026-10-05 16:39:38 +08:00
1 parent aad22df4fd
commit 9a38f86cd3
77 files changed
+8021 -899

No files matched your search

+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 1.0.0.0
version: 1.1.0.0
apiVersion: 2.0.0.0
author: 班级小助手
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.0.0.0";
private const string PluginVersion = "1.1.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
@@ -2,6 +2,18 @@ appId: com.classhelper.desktop
productName: 班级小助手
copyright: Copyright © 2026 Class Helper
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
# 而本应用界面文案只有中文,其余 54 个语言包纯属白占体积(`win-unpacked` 372MB → 约 324MB)。
# 这是 electron-builder 的官方选项:`app-builder-lib` 的 removeUnusedLanguagesIfNeeded()
# 会对 `locales/*.pak` 做精确匹配删除,只留 `zh-CN.pak`(0.6MB)。
#
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
electronLanguages:
- zh-CN
directories:
output: release
buildResources: build
+2 -3
View File
@@ -1,13 +1,13 @@
{
"name": "@classhelper/desktop-client",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
"main": "dist/main/index.js",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer && npm run check:secrets",
"build": "node ../../scripts/check-icons.mjs && npm run build:main && npm run build:renderer && npm run check:secrets",
"build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build",
"check:secrets": "node scripts/check-bundle-secrets.mjs",
@@ -21,7 +21,6 @@
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
+142 -15
View File
@@ -42,6 +42,8 @@ interface PersistedConfig {
theme?: 'light' | 'dark';
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -56,6 +58,7 @@ const DEFAULT_CONFIG: PersistedConfig = {
onboardingDone: false,
theme: 'light',
sidebarCollapsed: false,
ignoredUpdateVersion: '',
};
/** 主题白名单:非法值一律回落浅色 */
@@ -179,7 +182,7 @@ function decryptToken(raw: PersistedConfig): string | null {
}
}
function readPersisted(): PersistedConfig {
function readPersistedFromDisk(): PersistedConfig {
try {
const content = fs.readFileSync(configFilePath(), 'utf8');
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
@@ -198,16 +201,125 @@ function readPersisted(): PersistedConfig {
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
};
} catch {
return { ...DEFAULT_CONFIG };
}
}
function writePersisted(config: PersistedConfig): void {
const target = configFilePath();
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
/* ------------------------------------------------------------ 内存缓存 + 异步合并写
*
* 为什么不能每次 saveConfig 都同步读写盘(原来的写法):
* 侧栏折叠(`stores/ui.ts` 的 toggleSidebar)、主题切换、通知渠道、作业看板偏好、登录/登出
* 全都走 `saveConfig`,而它一次调用要做「readFileSync + JSON.parse + 8 个 normalize」**两遍**
* (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` 一遍 + DPAPI 解密一遍,
* **全部同步跑在主进程主线程上**。主进程是 Electron 的消息泵进程,被它摁住的那一拍
* 渲染进程也跟着掉帧 —— 用户反馈的"点汉堡展开/收回侧栏时卡一下"就是这一拍。
*
* 现在:配置常驻内存(连已解密的 token 一起缓存),写盘改成**异步 + 合并** ——
* 同一拍内的多次改动只落最后一次,写入串行不会互相覆盖;退出前由 `flushConfigSync()` 兜底。
*
* **语义变化(有意为之)**:`saveConfig()` 返回时数据可能还没落盘(延迟 CONFIG_WRITE_DEBOUNCE_MS)。
* 正常退出(托盘 → 退出)会走 `flushConfigSync()`,不会丢最后一次改动;只有进程被强杀
* 才可能丢这最后 120ms 内的改动。
*/
/** 写入合并窗口:拖动同类控件(滑块/连续点击)时的连续改动只落最后一次 */
const CONFIG_WRITE_DEBOUNCE_MS = 120;
interface ConfigCache {
config: PersistedConfig;
/** 已解密的登录令牌(null = 未登录 / 解密失败)。缓存它是为了不再每次都走一遍 DPAPI */
token: string | null;
}
/**
* 内存配置。**必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)`
* 把配置目录指到冒烟专用 profile,之后才第一次读配置;在模块顶层立即读盘会读错文件。
*/
let cache: ConfigCache | null = null;
function loadCache(): ConfigCache {
if (cache) return cache;
const config = readPersistedFromDisk();
cache = { config, token: decryptToken(config) };
return cache;
}
/** 待写入的配置快照(合并窗口内只保留最后一次) */
let pendingWrite: PersistedConfig | null = null;
let writeTimer: ReturnType<typeof setTimeout> | null = null;
/** 是否有一次异步写入正在进行(用它把写入串行化,避免两次写互相覆盖) */
let writing = false;
/** 排一次落盘。合并窗口内的多次调用只会写最后一次;`immediate` 用于不该被延迟的场景 */
function scheduleWrite(config: PersistedConfig, immediate = false): void {
pendingWrite = config;
if (immediate) {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
void flushConfigAsync();
return;
}
// 已有待写任务(定时器在跑或正在写):内容已经记在 pendingWrite 里,等它自己收尾即可。
// 正在写的那次结束后会再检查一次 pendingWrite(见 flushConfigAsync 的 finally)。
if (writeTimer !== null || writing) return;
writeTimer = setTimeout(() => {
writeTimer = null;
void flushConfigAsync();
}, CONFIG_WRITE_DEBOUNCE_MS);
}
/** 异步落盘(不阻塞主进程消息泵)。失败只记日志 —— 与 readPersistedFromDisk 的容错口径一致 */
async function flushConfigAsync(): Promise<void> {
if (writing) return;
const target = pendingWrite;
if (!target) return;
pendingWrite = null;
writing = true;
// 先把内容序列化下来:写入期间的后续改动由 pendingWrite 负责补一次,
// 不会出现"这一次写的是半新半旧的对象"
const payload = JSON.stringify(target, null, 2);
try {
const file = configFilePath();
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(file, payload, 'utf8');
} catch (error) {
logger.warn(`写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`);
} finally {
writing = false;
// 写入期间又有改动:再写一次(内容已被 pendingWrite 合并成最后一次)
if (pendingWrite) void flushConfigAsync();
}
}
/**
* 退出前同步落地一次(`main/index.ts` 的 shutdownResources 调用)。
*
* 即使没有待写内容也把内存里的配置重写一遍:那一时刻可能正有一次异步写在进行中,
* 它未必来得及完成;退出时多写一次的代价可以忽略。
*/
export function flushConfigSync(): void {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
const target = pendingWrite ?? cache?.config ?? null;
if (!target) return;
pendingWrite = null;
try {
const file = configFilePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(target, null, 2), 'utf8');
} catch (error) {
logger.warn(
`退出前写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`,
);
}
}
/**
@@ -229,13 +341,13 @@ function normalizeServerUrl(input: string): string | null {
}
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted();
/** 由内存缓存组装对渲染进程暴露的配置(不再读盘、不再解密) */
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
const persisted = state.config;
return {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: decryptToken(persisted),
token: state.token,
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
@@ -243,12 +355,19 @@ export function getConfig(): DesktopStoredConfig {
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
};
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
return toDesktopConfig(loadCache());
}
/** 局部更新配置:只覆盖传入的字段 */
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
const persisted = readPersisted();
const state = loadCache();
const persisted = state.config;
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
const normalized = normalizeServerUrl(patch.serverUrl);
@@ -284,20 +403,28 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.sidebarCollapsed !== undefined) {
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
}
// 空串是合法值(= 没有忽略任何版本),因此这里只判类型不判空
if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
persisted.tokenEncrypted = encrypted.tokenEncrypted;
// 明文一并进缓存:既省掉一次 DPAPI 解密,也保证紧接着的 getConfig() 读到的就是刚存的令牌
state.token = patch.token;
}
writePersisted(persisted);
return getConfig();
scheduleWrite(persisted);
return toDesktopConfig(state);
}
/** 清空为默认配置(退出登录 / 重置) */
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
export function clearConfig(): DesktopStoredConfig {
writePersisted({ ...DEFAULT_CONFIG });
return getConfig();
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
cache = state;
scheduleWrite(state.config, true);
return toDesktopConfig(state);
}
export function getConfigPath(): string {
+10 -1
View File
@@ -1,12 +1,13 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { getConfig } from './config.js';
import { flushConfigSync, getConfig } from './config.js';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
import { scheduleStartupUpdateCheck } from './update.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
@@ -163,6 +164,9 @@ if (!gotLock) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -190,6 +194,11 @@ if (!gotLock) {
*/
function shutdownResources(): string {
const done: string[] = [];
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
flushConfigSync();
done.push('配置已落盘');
if (isTrayReady()) {
destroyTray();
done.push('托盘已销毁');
+19
View File
@@ -1,5 +1,7 @@
import { app, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
@@ -9,6 +11,8 @@ export const IPC_CHANNELS = {
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const;
export function registerIpcHandlers(): void {
@@ -39,6 +43,21 @@ export function registerIpcHandlers(): void {
await shell.openExternal(url);
return true;
});
/**
* 检查更新(走主进程直连 GitHub,见 main/update.ts 的说明)。
*
* `force` 为 true 时绕过主进程缓存 —— 对应「关于」页里用户主动点的那次检查;
* 启动自动检查一律用缓存(避免同一分钟内重复请求打满匿名限流)。
*/
ipcMain.handle(IPC_CHANNELS.updateCheck, (_event, force?: boolean): Promise<UpdateInfo> => {
return checkForUpdates(force === true);
});
/** 忽略某个版本的更新提示(记入 config.json,启动自动检查不再提示它) */
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version);
});
}
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
+34 -12
View File
@@ -958,9 +958,9 @@ class IslandController {
}
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
private targetDisplay(): Electron.Display {
private targetDisplay(cursor?: { x: number; y: number } | null): Electron.Display {
return this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
? screen.getDisplayNearestPoint(cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
}
@@ -970,10 +970,13 @@ class IslandController {
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
*
* 不需要额外监听 `screen` 的 display-* 事件:目标屏是由**当前光标位置**或主屏推出来的,
* 插拔/切主屏之后下一轮 tick 自然会算出不同的 id,一样能触发重排。
*/
private syncAnchorDisplay(): void {
private syncAnchorDisplay(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed()) return;
const displayId = this.targetDisplay().id;
const displayId = this.targetDisplay(cursor).id;
if (this.anchorDisplayId === displayId) return;
this.anchorDisplayId = displayId;
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
@@ -1409,11 +1412,14 @@ class IslandController {
/**
* 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。
* 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。
*
* `cursor`:调用方在本轮 tick 里已经读过的光标位置(见 `startHitPoll`)。
* 传进来就复用它,避免同一个 tick 里重复调 `screen.getCursorScreenPoint()`。
*/
private cursorLocal(): { x: number; y: number } | null {
private cursorLocal(cursor?: { x: number; y: number } | null): { x: number; y: number } | null {
if (!this.win || this.win.isDestroyed()) return null;
const bounds = this.win.getBounds();
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
const point = cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint();
return { x: point.x - bounds.x, y: point.y - bounds.y };
}
@@ -1494,7 +1500,7 @@ class IslandController {
}
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(): void {
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见');
return;
@@ -1505,18 +1511,34 @@ class IslandController {
this.setInteractive(false, '隐藏态');
return;
}
this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询');
this.setInteractive(this.isInsideHitRect(this.cursorLocal(cursor)), '光标轮询');
}
private startHitPoll(): void {
if (this.hitTimer) return;
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
//
// 每一跳的成本要压到最低(这个定时器从岛初始化一直跑到进程退出):
// 1) 光标位置**每跳只读一次**,命中判定与"跟随鼠标屏幕"共用 —— 原先 cursorLocal() 与
// targetDisplay() 各读一次,等于每跳两次 `screen.getCursorScreenPoint()`;
// 2) 窗口不可见时直接返回:此时命中判定与心跳检查本来就各自早退(窗口不可见 ⇒ 无命中可言),
// 留着那两次系统调用纯属白跑。
this.hitTimer = setInterval(() => {
if (!this.win || this.win.isDestroyed()) return;
const visible = this.win.isVisible();
// 不可见、且不跟随鼠标屏幕时,连读光标都可省掉(targetDisplay 会走主屏分支)
const cursor =
visible || this.appearance.followCursorDisplay
? (this.cursorOverride ?? screen.getCursorScreenPoint())
: null;
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
this.syncAnchorDisplay();
this.syncHitFromCursor();
this.syncAnchorDisplay(cursor);
if (!visible) return;
this.syncHitFromCursor(cursor);
this.checkRendererAlive();
}, 60);
// 定时器不应拖住进程退出
@@ -1748,12 +1770,12 @@ const MIN_CARD_HEIGHT = 120;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
* 超过这个时间没上报就认为它已经卡死/不响应。
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
* 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后
* 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。
*/
const RENDERER_ALIVE_INTERVAL_MS = 5_000;
const RENDERER_ALIVE_TIMEOUT_MS = 20_000;
/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */
const RECOVER_MAX_TIMES = 3;
+63 -12
View File
@@ -15,6 +15,7 @@ import {
ISLAND_URGENT_SIZE,
} from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
import { checkForUpdates, wasStartupCheckSkipped } from './update.js';
interface SmokeResult {
name: string;
@@ -555,11 +556,26 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
);
if (buttonPoint) {
const bounds = win.getBounds();
island.setHitTestCursor({ x: Math.round(bounds.x + buttonPoint.x), y: Math.round(bounds.y + buttonPoint.y) });
island.setHitTestCursor({
x: Math.round(bounds.x + buttonPoint.x),
y: Math.round(bounds.y + buttonPoint.y),
});
await sleep(160);
win.webContents.sendInputEvent({ type: 'mouseDown', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({
type: 'mouseDown',
x: buttonPoint.x,
y: buttonPoint.y,
button: 'left',
clickCount: 1,
});
await sleep(40);
win.webContents.sendInputEvent({ type: 'mouseUp', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({
type: 'mouseUp',
x: buttonPoint.x,
y: buttonPoint.y,
button: 'left',
clickCount: 1,
});
}
const deadline = Date.now() + 1600;
let index = 0;
@@ -581,14 +597,8 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
const crop = image.crop({
x: Math.max(0, Math.round((geometry.window.x - pad) * shotScale)),
y: Math.max(0, Math.round((geometry.window.y - pad) * shotScale)),
width: Math.min(
image.getSize().width,
Math.round((geometry.window.width + pad * 2) * shotScale),
),
height: Math.min(
image.getSize().height,
Math.round((geometry.window.height + pad * 2) * shotScale),
),
width: Math.min(image.getSize().width, Math.round((geometry.window.width + pad * 2) * shotScale)),
height: Math.min(image.getSize().height, Math.round((geometry.window.height + pad * 2) * shotScale)),
});
fs.writeFileSync(path.join(dir, `${label}-${String(index).padStart(2, '0')}.png`), crop.toPNG());
lines.push(
@@ -2087,7 +2097,8 @@ async function runIslandChecks(
let count = 0;
for (let y = Math.max(0, fromY); y < Math.min(collapseShotSize.height, toY); y += 2) {
for (let x = Math.max(0, fromX); x < Math.min(collapseShotSize.width, toX); x += 2) {
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h) continue;
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h)
continue;
const index = (y * collapseShotSize.width + x) * 4;
if (index + 3 >= bitmap.length) continue;
const b = bitmap[index];
@@ -4349,6 +4360,46 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? ''));
// 更新检查(新功能回归):主进程直连 GitHub 取最新 Release。
//
// **刻意不断言"一定有新版本"**:那会把冒烟绑死在外网可达上,而教室机器常常没有外网
// (`ok=false` 是设计内的降级结果,不是失败)。因此两种结果都算通过,验的是:
// 结构完整、失败时有可读原因、以及缓存真的生效。
const updateFirst = await checkForUpdates(true);
const isVersionLike = (value: unknown): boolean => typeof value === 'string' && /^\d/.test(value);
const updateShapeOk =
isVersionLike(updateFirst.currentVersion) &&
typeof updateFirst.ok === 'boolean' &&
typeof updateFirst.hasUpdate === 'boolean' &&
(updateFirst.ok
? isVersionLike(updateFirst.latestVersion) &&
Array.isArray(updateFirst.assets) &&
/^https:\/\//.test(updateFirst.releaseUrl) &&
updateFirst.error === null
: typeof updateFirst.error === 'string' && updateFirst.error.length > 0);
record(
'更新检查返回结构正确(无外网时降级为 ok=false 且带原因)',
updateShapeOk,
`ok=${updateFirst.ok} 本机=${updateFirst.currentVersion} 最新=${updateFirst.latestVersion ?? '-'} ` +
`有新版本=${updateFirst.hasUpdate} 附件=${updateFirst.assets.length}` +
`${updateFirst.error ? ` 原因=${updateFirst.error}` : ''}`,
);
// 缓存:紧接着再查一次(不传 force)应命中缓存 —— checkedAt 不变即说明没有二次请求
const updateSecond = await checkForUpdates();
record(
'更新检查结果带缓存(不重复请求 GitHub)',
updateSecond.checkedAt === updateFirst.checkedAt,
`两次 checkedAt 相同=${updateSecond.checkedAt === updateFirst.checkedAt}`,
);
// 启动自动检查在冒烟模式下必须被跳过,否则每次验证都会产生一次真实网络请求与提示条
record(
'冒烟模式下跳过启动自动检查(验证不依赖外网)',
wasStartupCheckSkipped(),
`skipped=${wasStartupCheckSkipped()}`,
);
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{
ok: boolean;
+215
View File
@@ -0,0 +1,215 @@
import { app, type BrowserWindow } from 'electron';
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
/**
* 更新检查(桌面客户端)。
*
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
*
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
let startupTimer: ReturnType<typeof setTimeout> | null = null;
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
let startupCheckSkipped = false;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: app.getVersion(),
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion,
latestVersion,
hasUpdate: isNewerVersion(currentVersion, latestVersion),
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
*
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
*/
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
const currentVersion = app.getVersion();
const now = Date.now();
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
// GitHub API 强制要求 User-Agent,不带会直接 403
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
logger.info(
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
}
cache = { at: now, info };
return info;
}
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
export function ignoreUpdateVersion(version: string): void {
const normalized = normalizeVersion(version);
if (!normalized) return;
saveConfig({ ignoredUpdateVersion: normalized });
logger.info(`已忽略版本 ${normalized} 的更新提示`);
}
/** 该版本是否已被用户忽略 */
export function isUpdateIgnored(version: string): boolean {
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
}
/**
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
*
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
*/
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
startupCheckSkipped = true;
logger.info('更新检查:冒烟模式下跳过启动自动检查');
return;
}
if (startupTimer) clearTimeout(startupTimer);
startupTimer = setTimeout(() => {
startupTimer = null;
void (async () => {
try {
const info = await checkForUpdates();
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
if (isUpdateIgnored(info.latestVersion)) {
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
return;
}
const win = getWindow();
if (!win || win.isDestroyed()) return;
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
} catch (error) {
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
}
})();
}, STARTUP_DELAY_MS);
// 不阻止进程退出:这个定时器只是"顺便查一下"
startupTimer.unref?.();
}
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
export function wasStartupCheckSkipped(): boolean {
return startupCheckSkipped;
}
+11 -1
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
import type {
DesktopAppInfo,
DesktopBridge,
@@ -7,7 +8,7 @@ import type {
IslandClassStatePayload,
IslandPushContext,
} from '../types/desktop.js';
import type { IslandNotification } from '@classhelper/shared';
import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
/**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
@@ -37,6 +38,15 @@ const bridge: DesktopBridge = {
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
/* 更新检查(主进程直连 GitHub,见 main/update.ts) */
checkForUpdates: (force = false): Promise<UpdateInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force),
ignoreUpdateVersion: (version: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version),
onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => {
ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info));
},
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload);
},
+52 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { h, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import {
startIslandBridge,
stopIslandBridge,
@@ -24,6 +26,52 @@ const realtime = useRealtimeStore();
const booting = ref(true);
const bootText = ref('正在读取本地配置...');
/**
* 启动自动检查发现新版本时的提示(主进程查出后推过来,见 main/update.ts)。
*
* **为什么订阅放在 App.vue**:`onUpdateAvailable` 底层是 `ipcRenderer.on`,
* 桥接层没有对应的 off —— 挂在会随"登出 → 登录"反复挂载的 ClientLayout 里,
* 处理器会越堆越多(与灵动岛订阅同一个坑)。App.vue 整个应用生命周期只挂载一次。
*
* 提示是**一次性**且可永久消音:点「忽略此版本」写进 config.json,同一版本不再提示;
* 「关于」页里仍可随时手动检查。
*/
function showUpdateAvailable(info: UpdateInfo): void {
if (!info.latestVersion) return;
ElNotification({
title: `发现新版本 v${info.latestVersion}`,
type: 'info',
// 不自动消失:这是需要用户决定的事(去下载 / 忽略),滑过去就只能靠「关于」页再想起来
duration: 0,
message: h('div', { style: 'line-height: 1.7' }, [
h('p', { style: 'margin: 0 0 10px' }, `当前版本 ${info.currentVersion},可前往下载更新。`),
h('div', { style: 'display: flex; gap: 8px' }, [
h(
ElButton,
{
size: 'small',
type: 'primary',
onClick: () => {
void window.desktop?.openExternal(info.releaseUrl);
},
},
() => '前往下载',
),
h(
ElButton,
{
size: 'small',
onClick: () => {
void window.desktop?.ignoreUpdateVersion(info.latestVersion ?? '');
},
},
() => '忽略此版本',
),
]),
]),
});
}
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
@@ -59,6 +107,9 @@ onMounted(async () => {
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
+105 -30
View File
@@ -103,9 +103,14 @@ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
{ label: '通用', path: '/settings/general' },
];
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
/**
* 一级菜单项:2026-10-05 起「学习」分组已拆掉,课表/作业/通知/成绩**直接铺在侧栏一级**。
* 下面的结构自检会确认它们真的不在任何 `el-sub-menu` 里。
*/
const PRIMARY_MENU_ITEMS = ['课表', '作业', '通知', '成绩'];
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项。设置组自成一类,保留分组 */
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
];
@@ -122,16 +127,18 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
Array.from(element.querySelectorAll('span')).some(
(span) => (span.textContent ?? '').trim() === label,
),
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === label),
);
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
const findGroupTitle = (title: string): HTMLElement | undefined =>
findByLabel('.el-sub-menu__title', title);
if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
if (!findGroupTitle('设置')) {
return { ok: false, detail: '侧边栏「设置」分组未渲染(当前不在主布局或未登录)' };
}
const missingPrimary = PRIMARY_MENU_ITEMS.filter((label) => !findMenuItem(label));
if (missingPrimary.length > 0) {
return { ok: false, detail: `侧边栏缺少一级项:${missingPrimary.join('、')}` };
}
const visited: string[] = [];
@@ -150,7 +157,13 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
}
}
// 分组结构自检:两个分组各自应包含全部子项
// 结构自检:
// ① 一级项必须**真的在一级** —— 不能还挂在某个 el-sub-menu 里(用户要求把"学习"拆掉);
// ② 分组各自应包含全部子项
for (const label of PRIMARY_MENU_ITEMS) {
const element = findMenuItem(label);
if (element?.closest('.el-sub-menu')) failures.push(`${label}(仍挂在分组里,应在一级)`);
}
for (const group of MENU_GROUPS) {
for (const label of group.labels) {
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
@@ -874,12 +887,8 @@ export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
const clicks: string[] = [];
for (let attempt = 0; attempt < 12; attempt += 1) {
const action = document.querySelector('.onboarding-welcome [data-action="finish"]')
? 'finish'
: 'next';
const button = document.querySelector<HTMLElement>(
`.onboarding-welcome [data-action="${action}"]`,
);
const action = document.querySelector('.onboarding-welcome [data-action="finish"]') ? 'finish' : 'next';
const button = document.querySelector<HTMLElement>(`.onboarding-welcome [data-action="${action}"]`);
if (!button) break;
clicks.push(action);
button.click();
@@ -957,20 +966,48 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
const configAfterFlip = await window.desktop?.getConfig?.();
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
// 1.5) 顶栏右侧必须水平对齐(用户反馈"深色模式没对齐",两种主题其实都偏)。
// Element Plus 给按钮的是 `vertical-align:middle`、给下拉容器的是 `vertical-align:top`,
// 容器不是 flex 的话两个 inline-level 子元素会各按各的规则落位,错开几像素。
const centerYOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? rect.top + rect.height / 2 : null;
};
const themeToggleY = centerYOf(document.querySelector('[data-test="theme-toggle"]'));
const userChipY = centerYOf(document.querySelector('.user-chip'));
const headerCenterDelta =
themeToggleY !== null && userChipY !== null ? Math.abs(themeToggleY - userChipY) : null;
const headerAligned = headerCenterDelta !== null && headerCenterDelta <= 1.5;
// 2) 侧边栏:点汉堡折叠 → 图标导轨态 + 宽度收窄(是动画,要等它走完)→ 配置落盘 → 再展开还原
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthBefore = asideBefore?.offsetWidth ?? 0;
// 2.0) 折叠必须是**动画**而不是瞬切(用户要求"重写菜单的动画")。
// 不去抓中间帧(时序敏感),直接断言侧栏上挂着 width 的过渡 —— 确定性的。
const asideComputed = asideBefore ? getComputedStyle(asideBefore) : null;
const asideTransitionProperty = asideComputed?.transitionProperty ?? '';
const asideTransitionSeconds = asideComputed ? Number.parseFloat(asideComputed.transitionDuration) : 0;
const widthAnimated =
asideTransitionProperty.includes('width') &&
Number.isFinite(asideTransitionSeconds) &&
asideTransitionSeconds > 0.05;
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
const collapsed = await waitUntil(() => {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 80;
}, 4000);
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthAfter = asideAfter?.offsetWidth ?? 0;
const configAfterCollapse = await window.desktop?.getConfig?.();
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
// 干扰就会留"半截字");
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
// a) 菜单里不允许残留任何**可见**的文字。折叠实现是"文字宽度被 flex 压到 0 + overflow:hidden
// 裁掉 + opacity 归零"(`display:none` 无法过渡,用不了),所以判据是"看得见"
// —— 有宽度且未透明 —— 而不是"根本没参与布局";
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px)。
// 分组标题里还有个绝对定位的折叠箭头,它同样带 el-icon 类但已淡出,量它没有意义,排除。
const centerXOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
@@ -983,24 +1020,48 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
const element = node as HTMLElement;
if (element.classList.contains('menu-icon-slot')) continue;
const text = (element.textContent ?? '').trim();
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
const rect = element.getBoundingClientRect();
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
else leftoverText.push(`可见:${text.slice(0, 6)}`);
}
if (!text || element.getClientRects().length === 0) continue;
const rect = element.getBoundingClientRect();
const opacity = Number(getComputedStyle(element).opacity || '1');
if (rect.width >= 1 && opacity > 0.05) leftoverText.push(`可见:${text.slice(0, 6)}`);
}
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
const iconCenters = Array.from(
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
document.querySelectorAll(
'.aside.is-collapsed .menu-icon-slot .el-icon, ' +
'.aside.is-collapsed .el-sub-menu__title > .el-icon:not([class*="icon-arrow"])',
),
).map((node) => centerXOf(node));
const iconDeltas = iconCenters
.filter((value): value is number => value !== null && toggleCenter !== null)
.map((value) => Math.abs(value - (toggleCenter as number)));
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
const settingsItem = Array.from(
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
).find((element) =>
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === '通用'),
);
const submenuOpen = (): boolean => Boolean(settingsItem && settingsItem.offsetParent !== null);
if (!submenuOpen()) {
// el-sub-menu__title 的点击是 **toggle**:先判"子项是否可见"再点,
// 盲点一次会把已展开的组收起来(AGENTS §5 第 49 条)
document.querySelector<HTMLElement>('.aside .el-sub-menu__title')?.click();
await waitUntil(() => submenuOpen(), 2000);
}
const railMenu = document.querySelector<HTMLElement>('.aside .menu');
const railOverflowPx = railMenu ? railMenu.scrollHeight - railMenu.clientHeight : Number.NaN;
const railFits = Number.isFinite(railOverflowPx) && railOverflowPx <= 1;
clickToggle('[data-test="sidebar-toggle"]');
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
// 展开同样是动画:等宽度回到 200 再继续,否则下面量页脚时侧栏还停在中间宽度
const expandedBack = await waitUntil(() => {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
return !aside?.classList.contains('is-collapsed') && (aside?.offsetWidth ?? 0) >= 190;
}, 4000);
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
@@ -1018,20 +1079,27 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
const ok =
flipped &&
themePersisted &&
headerAligned &&
widthAnimated &&
collapsed &&
collapsePersisted &&
widthAfter < widthBefore &&
widthAfter <= 80 &&
expandedBack &&
leftoverText.length === 0 &&
iconsAligned &&
railFits &&
footerVisible;
return {
ok,
detail:
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
`顶栏对齐=${headerAligned}(中线差=${headerCenterDelta === null ? '-' : headerCenterDelta.toFixed(1)}px,要求 ≤1.5px) ` +
`宽度过渡=${widthAnimated}(${asideTransitionProperty} ${asideTransitionSeconds}s) ` +
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 展开还原=${expandedBack} 落盘=${collapsePersisted} ` +
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
`导轨无溢出=${railFits}(溢出=${Number.isFinite(railOverflowPx) ? `${railOverflowPx}px` : '-'}) ` +
`侧栏底部可见=${footerVisible}`,
};
} catch (error) {
@@ -1063,10 +1131,17 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
const info = await window.desktop?.getAppInfo?.();
const hasConfigPath = Boolean(info?.configPath);
// 检查更新入口(新功能回归):关于页要有这一块,且按钮可点。
// 这里只断言"入口存在"——是否真有新版本取决于 GitHub,不该绑进冒烟(见 main/update.ts)
const updateBlock = document.querySelector('[data-test="update-check"]');
const updateButton = document.querySelector('[data-test="update-check-button"]');
await router.push('/schedule');
return {
ok: hasThanks && hasDiagnostics && hasConfigPath,
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
ok: hasThanks && hasDiagnostics && hasConfigPath && Boolean(updateBlock) && Boolean(updateButton),
detail:
`鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'} ` +
`检查更新块=${updateBlock ? '有' : '无'} 按钮=${updateButton ? '有' : '无'}`,
};
} catch (error) {
return {
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed } from 'vue';
/**
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
*
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
* 而 `import * as echarts from 'echarts'` 会给渲染产物加 1.1MB(客户端里最大的一块),
* 按需引入 echarts/core 也仍有约 500KB。
*
* 实现要点(决定了这里为什么不需要 resize 监听):
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
* 柱子的 `height: X%` 直接按它解析;
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 30px),因此永远对齐。
*/
interface BarItem {
label: string;
value: number;
}
const props = withDefaults(
defineProps<{
/** 每根柱:分类名 + 数值(0 ~ max) */
items: BarItem[];
/** 柱色 */
color?: string;
/** 纵轴上限 */
max?: number;
/** 数值单位(显示在刻度与柱顶数值后面) */
unit?: string;
/** 图表高度(px) */
height?: number;
}>(),
{ color: '#409eff', max: 100, unit: '%', height: 300 },
);
/** 纵轴刻度(自上而下):100 / 75 / 50 / 25 / 0(按 max 换算) */
const ticks = computed(() => [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(props.max * ratio)));
function barHeight(value: number): string {
const ratio = props.max > 0 ? value / props.max : 0;
return `${Math.min(1, Math.max(0, ratio)) * 100}%`;
}
function formatValue(value: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
}
</script>
<template>
<div class="bar-chart" :style="{ height: `${height}px` }">
<div v-if="items.length === 0" class="bar-chart-empty">暂无数据</div>
<template v-else>
<div class="axis-y" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
</div>
<div class="plot">
<div class="gridlines" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
</div>
<div class="bars">
<div v-for="item in items" :key="item.label" class="bar-col">
<div class="bar-value">{{ formatValue(item.value) }}</div>
<div class="bar-track">
<div
class="bar"
:style="{ height: barHeight(item.value), background: color }"
:title="`${item.label}:${formatValue(item.value)}`"
></div>
</div>
<div class="bar-label" :title="item.label">{{ item.label }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.bar-chart {
display: grid;
grid-template-columns: 46px 1fr;
width: 100%;
}
.bar-chart-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--ch-text-tertiary);
}
/* 纵轴刻度:上下留白与 .bars 里的数值行 / 标签行一一对应 */
.axis-y {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 0 30px;
}
.tick {
height: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
color: var(--ch-text-tertiary);
}
.plot {
position: relative;
min-width: 0;
}
.gridlines {
position: absolute;
inset: 18px 0 30px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.gridline {
height: 0;
border-top: 1px solid var(--ch-border);
}
.bars {
display: flex;
align-items: stretch;
gap: 10px;
height: 100%;
padding: 0 6px;
}
.bar-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.bar-value {
flex: none;
height: 18px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
color: var(--ch-text-secondary);
}
.bar-track {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 100%;
max-width: 46px;
border-radius: 4px 4px 0 0;
transition: height 0.2s ease;
}
.bar-label {
flex: none;
height: 30px;
padding-top: 6px;
font-size: 11px;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ch-text-secondary);
}
</style>
@@ -0,0 +1,94 @@
import type { App } from 'vue';
import {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} from '@element-plus/icons-vue';
/**
* Element Plus 图标注册表(桌面客户端渲染进程)。
*
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 33 个),
* 全量注册既把死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
*
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
* (`ClientLayout.vue` 的菜单配置 `icon: 'Setting'`、`OnboardingWelcome.vue` 的引导步骤、
* 按钮上的 `:icon="'FullScreen'"`、`<component :is="item.icon">`),Element Plus 会用
* `<component :is="'Setting'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
*
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错,
* 不会静默渲染成空白。
*/
const APP_ICONS = {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} as const;
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort();
export function registerAppIcons(app: App): void {
for (const [name, component] of Object.entries(APP_ICONS)) {
app.component(name, component);
}
}
@@ -19,22 +19,24 @@ const notifications = useNotificationStore();
const ui = useUiStore();
/**
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
* - 学习:课表 / 作业 / 通知 / 成绩
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
* 主侧边栏。
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
const menuGroups = [
{
title: '学习',
icon: 'Reading',
items: [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
],
},
{
title: '设置',
icon: 'Setting',
@@ -60,19 +62,19 @@ const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
return group?.title ?? '学习';
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**。
* 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。
*
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
* 用户手动展开其它分组不受限制(可多个同时展开)。
* 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、
* 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组),
* 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>([activeGroupTitle.value]);
const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.value] : []);
watch(
() => route.path,
@@ -166,7 +168,11 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
<el-aside
:width="ui.sidebarCollapsed ? '64px' : '200px'"
class="aside ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
>
<div class="brand">
<button
type="button"
@@ -178,22 +184,35 @@ onUnmounted(() => {
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
</button>
<template v-if="!ui.sidebarCollapsed">
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</template>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
:default-openeds="openedGroups"
:collapse="ui.sidebarCollapsed"
:collapse-width="64"
class="menu"
@select="handleMenuSelect"
>
<!-- 一级项:直接铺在侧栏上(不再套「学习」分组)。
下面 el-sub-menu 里的菜单项与本段是同一套标记(图标槽 + 未读红点 + 标题),
改动时两处要一起改。 -->
<el-menu-item v-for="item in primaryItems" :key="item.path" :index="item.path">
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
@@ -203,13 +222,19 @@ onUnmounted(() => {
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
<!-- 同上:折叠时不删节点,只淡出并收掉内边距 -->
<div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div>
@@ -285,6 +310,11 @@ onUnmounted(() => {
.aside {
display: flex;
flex-direction: column;
/* 展开/收回动画:宽度来自 el-aside 的内联 --el-aside-width,这里只负责过渡。
overflow:hidden 是必需的 —— 收窄过程中文字要被栏边裁掉,不能溢出到主区域;
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
overflow: hidden;
transition: width var(--nav-dur) var(--nav-ease);
}
.brand {
@@ -296,6 +326,9 @@ onUnmounted(() => {
font-weight: 600;
font-size: 14px;
color: var(--ch-text);
transition:
padding-left var(--nav-dur) var(--nav-ease),
gap var(--nav-dur) var(--nav-ease);
}
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
@@ -318,10 +351,14 @@ onUnmounted(() => {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
.aside.ch-nav:has(.el-menu--collapse) .brand {
justify-content: center;
padding: 0;
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
用**位移 padding-left** 而不是 `justify-content: center` —— 后者不可过渡,会在动画中途"跳"一下。
汉堡宽 28px,padding-left 18px ⇒ 中心 = 18 + 14 = 32px,正好是 64/2,与折叠后的图标导轨同列
(冒烟断言中心差 ≤ 2px)。
这里刻意用**自己的类名**(`.is-collapsed`)而不是 `:has(.el-menu--collapse)`:
`:has()` 的失效会让 Chromium 标记整棵文档树重新计算样式。 */
.aside.ch-nav.is-collapsed .brand {
padding-left: 18px;
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
@@ -331,13 +368,56 @@ onUnmounted(() => {
justify-content: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
overflow: hidden;
transition:
width var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/*
* 品牌文字 / 菜单文字在折叠时**不靠 max-width 归零**(那要么写死一个魔法数字,要么
* `none → 0` 根本不可过渡),而是:
* - `min-width: 0` 让这个 flex 子项能被压缩(默认的 `min-width: auto` 会撑住内容宽度);
* - 宽度自然跟着栏宽收缩,多出来的字被 `overflow: hidden` 裁掉;
* - 再叠一层 opacity 淡出,避免在中间帧里留下"半截字"。
* 于是文字宽度是**跟着侧栏宽度连续变化**的,不需要给它单独设动画终点。
*/
.brand-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.2px;
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 折叠:品牌区收成 0 宽并淡出(不是删节点,理由见模板上的注释) */
.aside.ch-nav.is-collapsed .brand {
gap: 0;
}
/*
* 图标导轨里**不显示滚动条**:所有图标应当一眼看全(用户要求"不要用滚轮、删掉滚动条")。
* 导轨之所以放得下,靠的是页脚在折叠态把高度也收成 0(见 .is-collapsed .aside-footer)——
* 不再有那 ~70px 的留白,11 行图标(462px)在 540px 的最小窗口里也够。
* 这里只隐藏滚动条本身,`overflow-y: auto` 保留:窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
* 展开态**不隐藏** —— 那一栏确实会超(设置组展开时约 606px > 582px),
* 滚动条是"下面还有内容"的唯一提示,不能拿掉。
*/
.aside.ch-nav.is-collapsed .menu {
scrollbar-width: none;
}
.aside.ch-nav.is-collapsed .brand-logo {
width: 0;
opacity: 0;
}
.aside.ch-nav.is-collapsed .brand-text {
opacity: 0;
}
.menu {
@@ -393,6 +473,30 @@ onUnmounted(() => {
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid var(--ch-divider);
overflow: hidden;
/* max-height 是给折叠动画用的终点:`* { box-sizing: border-box }`,展开态给一个略高于
内容(约 70px)的值,折叠时归零 —— 导轨里**一个像素都不给页脚留**。
不这么做的话,文字淡出后仍占着 ~70px,10 个图标在 540px 的最小窗口里就放不下、被迫滚动
(用户反馈:"图标无法完全显示、需要上下滑动、滚动条碍眼")。 */
max-height: 96px;
transition:
opacity calc(var(--nav-dur) * 0.7) var(--nav-ease),
padding var(--nav-dur) var(--nav-ease),
max-height var(--nav-dur) var(--nav-ease),
border-top-color var(--nav-dur) var(--nav-ease);
}
/* 折叠:页脚淡出、内边距与高度一起收到 0,分隔线也透明掉。
`min-height: 0` 是必需的:flex 子项默认 `min-height: auto`(= 内容高度),
而 CSS 里 min-height 优先于 max-height —— 不显式归零的话 max-height:0 根本压不下去。 */
.aside.ch-nav.is-collapsed .aside-footer {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
max-height: 0;
min-height: 0;
border-top-color: transparent;
pointer-events: none;
}
.aside-meta {
@@ -425,6 +529,17 @@ onUnmounted(() => {
color: var(--ch-text-tertiary);
}
.header-right {
/* 必须是 flex:Element Plus 的按钮是 `vertical-align: middle`、下拉容器是 `vertical-align: top`,
放在普通 block 容器里两个 inline-level 子元素会按各自的垂直对齐规则落位,主题切换按钮与
用户名牌就会错开几像素(用户反馈的"深色模式没对齐",其实两种主题下都偏,只是深色下更显眼)。
与 Web 管理端 AdminLayout 的同名容器保持同一套写法。 */
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.user-chip {
display: flex;
align-items: center;
@@ -441,7 +556,7 @@ onUnmounted(() => {
}
.theme-toggle {
margin-right: 2px;
/* 与用户名牌的间距由 .header-right 的 gap 负责,这里不再自带 margin */
color: var(--ch-text-secondary);
}
+10 -5
View File
@@ -2,29 +2,34 @@ 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';
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
import { registerAppIcons } from './icons.js';
import './styles/index.css';
const app = createApp(App);
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
registerAppIcons(app);
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
//
// **动态 import 是刻意的**:`cache/self-test.ts` 有 1000 多行自检代码,
// 静态 import 会把它打进渲染进程入口 chunk,每次启动都要解析一遍。改成动态 import 后
// Vite/Rollup 会把它切成独立 chunk,只在冒烟模式下才加载(`verify:desktop` /
// `verify:packaged` 的那条路径)。
//
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) {
const { registerSmokeHooks, setSmokeTeacherCredentials } = await import('./cache/self-test.js');
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
@@ -53,6 +53,16 @@
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/*
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
* 改时长/缓动只改这里,两边不会漂。
*/
--nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* 图标与文字的间距(折叠时收到 0) */
--nav-gap: 8px;
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
@@ -389,56 +399,91 @@ textarea {
background: var(--ch-accent);
}
/* 折叠态:图标居中(el-menu collapse 自带收窄,这里压掉分组缩进) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
margin: 2px 8px;
padding: 0 !important;
}
/*
* 折叠态:把菜单文字**彻底不渲染**,只留图标槽。
/* ---------------------------------------------------------------- 折叠态:图标导轨
*
* Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",一旦外层样式
* (缩进、margin、transition)稍有干扰,文字就会以 0 宽溢出的形式留在画面上 ——
* 表现为折叠后菜单里残留"半截字"。这里直接 display:none,从根上消除残影。
* 图标槽(.menu-icon-slot)必须保留(它同时承载未读红点)。
* 折叠**不再用** el-menu 的 `:collapse`:那会把每个分组的子项卸载进浮层(一次结构切换),
* 并让整条菜单重排一遍 —— 既做不出连续动画,也是"点汉堡卡一下"的渲染侧来源。
* 现在改成纯粹的 CSS 收拢:栏宽、文字宽度、图标位置全是可过渡属性,全程没有结构变化,
* 折叠后就是一条图标导轨(分组标题与子项都以图标列出)。
*
* 几何(64px 栏、菜单项左右各 8px margin):
* 展开 一级项 padding-left 20px、二级项 40px(Element Plus 自带)→ 图标中心 40 / 60px
* 折叠 所有项 padding-left / padding-right 都是 12px → 图标中心 8 + 12 + 12 = 32px,
* 正好与顶部汉堡按钮的居中位置同列(冒烟断言中心差 ≤ 2px)。
* 图标槽(.menu-icon-slot)与分组标题里的裸图标都是 Element Plus 给的 24px 宽。
*/
.ch-nav .el-menu--collapse .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-menu--collapse .el-sub-menu__title > span:not(.menu-icon-slot) {
display: none !important;
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right:5px,
那会让图标在 24px 的槽里偏左 2.5px(折叠时肉眼可见地不居中)。 */
.ch-nav .el-menu-item [class^='el-icon'],
.ch-nav .el-sub-menu__title > [class^='el-icon'] {
margin-right: 0;
}
/* 折叠态:图标在 64px 栏内严格水平居中,且与顶部的汉堡按钮同一竖列(中心都在 32px) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
display: flex;
align-items: center;
justify-content: center;
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
transition:
padding-left var(--nav-dur) var(--nav-ease),
padding-right var(--nav-dur) var(--nav-ease),
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
/*
* 折叠态点分组图标会弹出子菜单浮层 —— 它被 teleport 到 body 下,
* 不受 .ch-nav 作用域影响,所以这些规则写在全局;不写的话深色主题下会是浅色浮层。
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width:0` 让这个 flex 子项
* 能被压到 0(默认的 `min-width:auto` 会撑住内容宽度),多出来的字由 `overflow:hidden` 裁掉。
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
* 避免中间帧留下"半截字"(`display:none` 没法过渡,用不了)。
*/
.el-menu--popup {
background: var(--ch-layer-solid);
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
box-shadow: var(--ch-shadow-flyout);
.ch-nav .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-sub-menu__title > span:not(.menu-icon-slot) {
min-width: 0;
margin-left: var(--nav-gap);
overflow: hidden;
white-space: nowrap;
transition:
margin-left var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
.el-menu--popup .el-menu-item {
color: var(--ch-text);
/* 分组标题右侧的折叠箭头:导轨里没有它的位置 */
.ch-nav .el-sub-menu__icon-arrow {
transition: opacity calc(var(--nav-dur) * 0.5) var(--nav-ease);
}
.el-menu--popup .el-menu-item:hover {
background: var(--ch-hover-soft);
.ch-nav.is-collapsed .el-menu-item > span:not(.menu-icon-slot),
.ch-nav.is-collapsed .el-sub-menu__title > span:not(.menu-icon-slot) {
margin-left: 0;
opacity: 0;
pointer-events: none;
}
.el-menu--popup .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
.ch-nav.is-collapsed .el-sub-menu__icon-arrow {
opacity: 0;
}
/*
* 折叠后的内边距。
*
* 这三条选择器形状都是**照抄 Element Plus 的**,不是冗余 —— 它按层级用三个不同的选择器算缩进
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)压不住任何一条 —— 实测踩过两次:
* 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
* 下面两条(0-5-0 / 0-6-0)把它们全覆盖,且不用 !important。
*/
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
padding-left: 12px;
padding-right: 12px;
}
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
padding-left: 12px;
padding-right: 12px;
}
/* ---------------------------------------------------------------- 课表时间轴 */
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
averageOf,
@@ -11,6 +10,7 @@ import {
} from '@classhelper/shared';
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import ScoreBarChart from '../components/ScoreBarChart.vue';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
@@ -24,8 +24,6 @@ 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
@@ -53,6 +51,21 @@ const levelCounts = computed(() => {
return levels;
});
/**
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
* echarts(渲染产物里最大的一块,1.1MB)。聚合口径与之前完全一致。
*/
const examAverages = computed(() => {
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);
}
return [...byExam.entries()].slice(-8).map(([label, percents]) => ({ label, value: averageOf(percents) }));
});
async function loadGrades(): Promise<void> {
loading.value = true;
try {
@@ -61,50 +74,11 @@ async function loadGrades(): Promise<void> {
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();
}
@@ -115,15 +89,11 @@ function onRecovered(): void {
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);
});
@@ -172,7 +142,7 @@ onUnmounted(() => {
<el-col :xs="24" :md="16">
<el-card v-loading="loading" shadow="never">
<template #header><span>各次考试平均得分率</span></template>
<div ref="chartRef" class="chart"></div>
<ScoreBarChart :items="examAverages" />
</el-card>
</el-col>
</el-row>
@@ -255,9 +225,4 @@ onUnmounted(() => {
gap: 6px;
margin-top: 8px;
}
.chart {
width: 100%;
height: 300px;
}
</style>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import type { DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
/**
* 关于(ClassIsland 同款折叠卡片式):
* 应用信息(版本/运行时/常用链接)→ 诊断信息(排查问题用)→ 鸣谢。
* 应用信息(版本/运行时/常用链接/检查更新)→ 诊断信息(排查问题用)→ 鸣谢。
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
*/
const appStore = useAppStore();
@@ -14,6 +16,10 @@ const appInfo = ref<DesktopAppInfo | null>(null);
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
const expandedNames = ref(['app']);
/** 检查更新:结果由主进程直连 GitHub 得到(见 main/update.ts) */
const checking = ref(false);
const updateInfo = ref<UpdateInfo | null>(null);
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
@@ -31,9 +37,34 @@ async function openExternal(url: string): Promise<void> {
window.open(url, '_blank');
}
/**
* 检查更新。
*
* @param force true = 用户主动点击(绕过主进程缓存);false = 进页面时带出启动自动检查的结果
*/
async function checkUpdate(force: boolean): Promise<void> {
if (!window.desktop?.checkForUpdates) return;
checking.value = true;
try {
updateInfo.value = await window.desktop.checkForUpdates(force);
} finally {
checking.value = false;
}
}
/** 忽略这个版本:写进配置,启动自动检查不再提示它 */
async function ignoreVersion(): Promise<void> {
const version = updateInfo.value?.latestVersion;
if (!version) return;
await window.desktop?.ignoreUpdateVersion(version);
ElMessage.success(`已忽略 v${version} 的更新提示`);
}
onMounted(async () => {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
// 带出启动自动检查的结果(命中主进程缓存,不会再发一次请求)
void checkUpdate(false);
});
</script>
@@ -54,9 +85,7 @@ onMounted(async () => {
<el-icon><School /></el-icon>
{{ collapseItems[0].title }}
</span>
<span class="about-extra">
班级小助手 v{{ appInfo?.appVersion ?? '-' }}
</span>
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span>
</template>
<div class="about-section">
<p class="about-copy">
@@ -77,6 +106,51 @@ onMounted(async () => {
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 检查更新:直连 GitHub 看最新的 Release 是不是比本机新 -->
<div class="about-update" data-test="update-check">
<div class="about-update-head">
<span class="about-update-label">检查更新</span>
<el-button
size="small"
:loading="checking"
data-test="update-check-button"
@click="checkUpdate(true)"
>
检查更新
</el-button>
</div>
<div class="about-update-body">
<template v-if="checking">正在检查…</template>
<template v-else-if="!updateInfo">尚未检查</template>
<template v-else-if="!updateInfo.ok">
<!-- 教室机器没有外网是常态,这里只是"查不到",不是错误 -->
<span class="about-update-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
</template>
<template v-else-if="updateInfo.hasUpdate">
<el-tag size="small" type="success" effect="light">
发现新版本 v{{ updateInfo.latestVersion }}
</el-tag>
<span class="about-update-muted">当前 v{{ updateInfo.currentVersion }}</span>
<el-button
link
type="primary"
size="small"
data-test="update-download"
@click="openExternal(updateInfo.releaseUrl)"
>
前往下载
</el-button>
<el-button link size="small" @click="ignoreVersion">忽略此版本</el-button>
</template>
<template v-else>
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
<span class="about-update-muted">
v{{ updateInfo.currentVersion }} · {{ updateInfo.latestVersion }}
</span>
</template>
</div>
</div>
</div>
</el-collapse-item>
@@ -115,9 +189,18 @@ onMounted(async () => {
</template>
<div class="about-section">
<ul class="about-thanks">
<li><strong>ClassIsland</strong> —— 界面风格、设置页与「今天」时间轴的交互参考</li>
<li><strong>WinIsland</strong> —— 灵动岛的窗口形态与弹簧动画参考</li>
<li><strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong> —— 本项目赖以构建的开源基石</li>
<li>
<strong>ClassIsland</strong>
—— 界面风格、设置页与「今天」时间轴的交互参考
</li>
<li>
<strong>WinIsland</strong>
—— 灵动岛的窗口形态与弹簧动画参考
</li>
<li>
<strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong>
—— 本项目赖以构建的开源基石
</li>
</ul>
</div>
</el-collapse-item>
@@ -165,6 +248,42 @@ onMounted(async () => {
margin-right: 4px;
}
/* 检查更新:颜色一律走 --ch-* 变量(深色主题下不能写死浅色底/深色字) */
.about-update {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: var(--ch-layer-alt);
}
.about-update-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.about-update-label {
font-size: 12.5px;
font-weight: 600;
color: var(--ch-text-secondary);
}
.about-update-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: var(--ch-text);
}
.about-update-muted {
color: var(--ch-text-tertiary);
}
.about-thanks {
margin: 0;
padding-left: 18px;
+23
View File
@@ -7,6 +7,7 @@ import type {
IslandAppearance,
IslandNotification,
IslandState,
UpdateInfo,
} from '@classhelper/shared';
/** 作业页展示偏好(看板 / 列表,看板外观) */
@@ -58,6 +59,13 @@ export interface DesktopStoredConfig {
theme: 'light' | 'dark';
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
sidebarCollapsed: boolean;
/**
* 用户点过「忽略此版本」的版本号(空串 = 没忽略过)。
*
* 启动时的自动检查会跳过这个版本,避免每次开客户端都弹一次同样的提示;
* 在「关于」页手动检查时仍会正常显示结果。
*/
ignoredUpdateVersion: string;
}
export interface DesktopAppInfo {
@@ -121,6 +129,21 @@ export interface DesktopBridge {
getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>;
/* 更新检查 */
/**
* 检查 GitHub 上有没有新版本(主进程直连,不经后端)。
*
* @param force true = 绕过主进程缓存(对应「关于」页里用户主动点的那次检查)
*
* 返回的 `ok:false` 是**正常结果**(教室机器没有外网 / GitHub 限流 / 超时),不是异常,
* 界面按"暂时查不到更新"呈现即可。
*/
checkForUpdates(force?: boolean): Promise<UpdateInfo>;
/** 忽略某个版本的更新提示(写入 config.json,启动自动检查不再提示它) */
ignoreUpdateVersion(version: string): Promise<void>;
/** 订阅"启动自动检查发现新版本"(仅新版本且未被忽略时触发一次) */
onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
/* 灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
+4 -2
View File
@@ -29,8 +29,10 @@ CORS_ORIGIN=*
# bcrypt 加密强度
BCRYPT_ROUNDS=10
# 教师新增学生账号时未指定密码所使用的初始密码
# 新建班级 / 新增教师未指定密码时使用的初始密码
# (学生没有个人账号,因此没有 DEFAULT_STUDENT_PASSWORD —— 见 AGENTS.md §5 第 31 条)
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
TERM_START_DATE=2026-02-23
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
+2 -1
View File
@@ -4,7 +4,8 @@
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
// 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型,
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
+36 -1
View File
@@ -105,6 +105,34 @@ async function loginWithCandidates(username, passwords) {
return null;
}
/**
* 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
*
* 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
* 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
* `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
* 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
* 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
* 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
*
* 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
* 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
* 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
*/
async function drainPending(deviceToken) {
let total = 0;
for (let round = 0; round < 20; round += 1) {
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
if (items.length === 0) break;
for (const item of items) {
await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
total += 1;
}
}
return total;
}
async function main() {
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
@@ -182,7 +210,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '1.0.0.0',
pluginVersion: '1.1.0.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
@@ -237,6 +265,13 @@ async function main() {
);
// ---------------------------------------------------------------- 4. 下发提醒
// 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
// 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
const drained = await drainPending(deviceToken);
if (drained > 0) {
console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
}
const notify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
+55 -2
View File
@@ -67,6 +67,17 @@ async function main() {
}
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
// 版本号不再硬编码(见 lib/version.ts):健康检查必须暴露一个像样的版本,
// 更新检查要靠它判断"本机是不是旧版";同时确认 update 模块已进注册表。
const healthData = health.payload?.data ?? {};
record(
'健康检查暴露版本号与模块清单(含 update 模块)',
typeof healthData.version === 'string' &&
/^\d+\.\d+\.\d+/.test(healthData.version) &&
(healthData.modules ?? []).includes('update'),
`版本=${healthData.version} 模块数=${(healthData.modules ?? []).length}`,
);
// ---------------------------------------------------------------- 1. 登录
const teacherLogin = await api('/auth/login', {
method: 'POST',
@@ -2503,7 +2514,10 @@ async function main() {
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
});
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken });
const dbRestore = await api(`/database/backups/${backupName}/restore`, {
method: 'POST',
token: adminToken,
});
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNotice2Id,
@@ -2521,7 +2535,10 @@ async function main() {
}
const dbBackupList = await api('/database/backups', { token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, {
method: 'DELETE',
token: adminToken,
});
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
record(
'备份列表与删除(创建后可见,删除后消失)',
@@ -2612,6 +2629,42 @@ async function main() {
} 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`,
);
// ---------------------------------------------------------------- 更新检查
// 验的是"结构完整 + 权限 + 可降级",**刻意不断言"一定有新版本"** ——
// 那会把用例绑死在外网可达上,而学校机房/内网服务器查不到 GitHub 是常态
// (`ok:false` 是设计内的结果,不是失败)。真实联网取值在交付前人工验一次。
const updateAnon = await api('/update/check');
record('更新检查未登录 401', updateAnon.status === 401, `status=${updateAnon.status}`);
const updateRes = await api('/update/check', { token: adminToken });
const updateData = updateRes.payload?.data ?? {};
const isVersionLike = (value) => typeof value === 'string' && /^\d/.test(value);
record(
'更新检查(管理员):结构完整,本机版本取自 package.json',
updateRes.status === 200 &&
isVersionLike(updateData.currentVersion) &&
typeof updateData.ok === 'boolean' &&
typeof updateData.hasUpdate === 'boolean' &&
(updateData.ok
? isVersionLike(updateData.latestVersion) &&
Array.isArray(updateData.assets) &&
/^https:\/\//.test(updateData.releaseUrl ?? '') &&
updateData.error === null
: typeof updateData.error === 'string' && updateData.error.length > 0),
`ok=${updateData.ok} 本机=${updateData.currentVersion} 最新=${updateData.latestVersion ?? '-'} ` +
`有新版本=${updateData.hasUpdate} 附件=${(updateData.assets ?? []).length}` +
`${updateData.error ? ` 原因=${updateData.error}` : ''}`,
);
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
record(
'更新检查:教师可查(公开信息),但 force 仅管理员生效',
updateTeacherForced.status === 200 &&
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
+5 -3
View File
@@ -6,6 +6,7 @@ import { env } from './config/env.js';
import { pingDatabase } from './lib/db.js';
import { sendOk } from './lib/http.js';
import { logger } from './lib/logger.js';
import { APP_VERSION } from './lib/version.js';
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
@@ -37,7 +38,8 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
res,
{
status: 'ok',
version: '1.0.0',
// 版本号取自 package.json(见 lib/version.ts):更新检查要拿它跟 GitHub 上的 Release 比
version: APP_VERSION,
env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()),
database: {
@@ -68,7 +70,7 @@ function createRootHandler(webDistDir: string | null) {
res,
{
service: 'class-helper-server',
version: '1.0.0',
version: APP_VERSION,
environment: env.nodeEnv,
apiPrefix: API_PREFIX,
socket: '/socket.io',
@@ -115,7 +117,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz
-1
View File
@@ -156,7 +156,6 @@ export const env = {
.map((item) => item.trim())
.filter(Boolean),
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
+41
View File
@@ -0,0 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/**
* 服务端版本号。
*
* 为什么不再硬编码:`app.ts` 里原本写了两个字面量 `'1.0.0'`(健康检查 + 首页),
* 而版本号在仓库里已经有 5 份 package.json —— 更新检查要靠它判断"本机是不是旧版",
* 再留一份手写副本就一定会漂移。
*
* 取哪一份(按顺序):
* 1. `serverRoot/..` —— 打包形态下是运行时清单(`scripts/dist-server.mjs` 把**根**
* package.json 的 version 写进 `staging/package.json`),也就是产物命名用的那个版本;
* 2. `serverRoot` —— 开发形态下是 `packages/server/package.json`。
*
* 与 `config/env.ts` 加载 `.env` 的双候选写法同一思路:不依赖启动时的 cwd。
*/
function readVersion(): string {
const candidates = [
path.resolve(env.serverRoot, '..', 'package.json'),
path.resolve(env.serverRoot, 'package.json'),
];
for (const file of candidates) {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: unknown };
if (typeof parsed.version === 'string' && parsed.version.trim()) {
return parsed.version.trim();
}
} catch {
// 换下一个候选
}
}
logger.warn(`未能从 package.json 读到版本号(候选:${candidates.join('、')}),暂时按 0.0.0 处理`);
return '0.0.0';
}
export const APP_VERSION = readVersion();
+1 -7
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import express, { type Express, type RequestHandler } from 'express';
import express, { type Express } from 'express';
import { env } from '../config/env.js';
import { logger } from './logger.js';
@@ -100,9 +100,3 @@ export function mountWebAdmin(app: Express): string | null {
logger.info(`Web 管理端已挂载:${distDir}`);
return distDir;
}
/** 静态资源请求判断(用于日志降噪等场景) */
export const staticAssetMatcher: RequestHandler = (req, _res, next) => {
req.headers['x-classhelper-static'] = req.path.startsWith('/assets/') ? '1' : '0';
next();
};
+10 -1
View File
@@ -85,11 +85,20 @@ export function loginRateLimiter(): RequestHandler {
});
}
/** 请求耗时日志(生产 info,开发 debug) */
/**
* 请求耗时日志(生产 info,开发 debug)。
*
* 静态构建产物(`/assets/*`,带内容哈希、长缓存)**成功的请求不打点**:
* Web 管理端首屏一次就有十几个资源请求,逐条刷 info 会把真正有用的接口日志淹掉,
* 而且每条都要走一次 stdout 写入。失败的静态请求(404 / 500)照常记录 ——
* 那种情况恰恰是最需要看见的(产物缺失、路径不对)。
*/
export function requestLogger(): RequestHandler {
return (req, res, next) => {
const isStaticAsset = req.path.startsWith('/assets/');
const startedAt = process.hrtime.bigint();
res.on('finish', () => {
if (isStaticAsset && res.statusCode < 400) return;
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
if (res.statusCode >= 500) logger.error(message);
+2
View File
@@ -12,6 +12,7 @@ import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import { updateModule } from './update/update.module.js';
import type { ApiModule } from './module.types.js';
/**
@@ -36,4 +37,5 @@ export const apiModules: ApiModule[] = [
teachersModule,
dashboardModule,
databaseModule,
updateModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,32 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as updateService from './update.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本
*
* 为什么 Web 管理端不直连 GitHub:浏览器的请求受服务端下发的 CSP 约束,
* `connect-src` 只放行 `'self'` 与 WebSocket(见 `middleware/security.ts`),
* 直连 `api.github.com` 会被浏览器拦掉。走服务端转发还顺带让所有管理员共享同一份缓存。
*
* 桌面客户端不受这条约束(它由主进程直连),但两边拿到的 `UpdateInfo` 结构是一致的。
*
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
*/
router.get('/check', async (req, res) => {
const user = getAuthUser(req);
const force = req.query.force === '1' && user.role === 'ADMIN';
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
export const updateModule = defineModule({
name: 'update',
basePath: '/update',
router,
});
@@ -0,0 +1,152 @@
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { logger } from '../../lib/logger.js';
import { APP_VERSION } from '../../lib/version.js';
/**
* 更新检查:问 GitHub 上最新的 Release 是不是比本机新。
*
* 三个设计约束:
* 1. **失败是常态,不是异常**:学校的服务器常常在只有内网的机房网络里,
* 因此离线 / 限流 / 超时一律收敛成 `ok:false` + 一句人话,绝不抛给路由层(否则管理员看到的会是 500)。
* 2. **必须带 User-Agent**:GitHub API 对没有 UA 的请求直接 403。
* 3. **必须有缓存**:匿名调用限流是 60 次/小时/IP,而 Web 管理端每次打开弹窗都会问一次。
*
* 这里用 Node 内置的 `fetch`(Node 20+ / Electron 同源),不引入任何 HTTP 依赖 ——
* 这也是本服务端**唯一**的出网请求。
*/
/** GitHub Releases API 里我们真正用到的字段(其余忽略,避免把整个响应建模一遍) */
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: APP_VERSION,
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
// 拿不到版本号就当"查不到",不要伪造一个 latestVersion 出来
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion: APP_VERSION,
latestVersion,
hasUpdate: isNewerVersion(APP_VERSION, latestVersion),
// 优先用 Release 自己的 html_url,拿不到才回落到列表页
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句管理员能看懂的话 */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查更新(结果带缓存)。
*
* @param options.force 绕过缓存(由路由层限定为管理员才能生效)
*/
export async function checkForUpdates(options: { force?: boolean } = {}): Promise<UpdateInfo> {
const now = Date.now();
if (!options.force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
// 403 / 429 基本都是匿名限流(60 次/小时/IP)—— 给出可操作的提示而不是原始状态码
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${APP_VERSION})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload);
logger.info(
`更新检查:本机 ${APP_VERSION} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${APP_VERSION})`);
}
// 失败结果**也缓存**:否则机房断网时,管理员每点一次按钮都要白等一个超时。
cache = { at: now, info };
return info;
}
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
"noEmit": false,
"removeComments": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+19 -2
View File
@@ -1,9 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"removeComments": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/generated/**/*.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module",
+28
View File
@@ -27,6 +27,8 @@ export const API_PATHS = {
integrations: '/integrations',
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
database: '/database',
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
update: '/update',
dashboard: '/dashboard/summary',
} as const;
@@ -496,3 +498,29 @@ export const MAX_PAGE_SIZE = 200;
/** 教师与管理员角色集合,便于 RBAC 判断 */
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
/* ------------------------------------------------------------------ 更新检查 */
/**
* 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx),
* 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。
*
* 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP,
* 所以服务端与客户端都把结果缓存 `UPDATE_CHECK_CACHE_TTL_MS`,不要每次开页面都打一次。
*/
export const GITHUB_REPO = { owner: 'LoveChengke', repo: 'ClassHelper' } as const;
/** 最新 Release 的 API 地址(GitHub 保证它不含草稿与预发布版) */
export const GITHUB_RELEASES_LATEST_API = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`;
/** 给人看的 Release 列表页(「前往下载」按钮打开它) */
export const GITHUB_RELEASES_PAGE = `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases`;
/** GitHub API 强制要求 User-Agent,不带会直接 403 */
export const UPDATE_CHECK_USER_AGENT = 'ClassHelper-UpdateCheck';
/** 检查结果缓存时长(30 分钟):既避开匿名限流,也不至于让用户等太久才知道有新版本 */
export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
+2 -2
View File
@@ -41,7 +41,7 @@ export function canAssignTeachers(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */
/** 学生名单(增删学生、导入名单):仅管理员(学生没有账号,因此没有"重置密码"这一步) */
export function canManageRoster(role: UserRole): boolean {
return role === 'ADMIN';
}
@@ -92,7 +92,7 @@ export const PERMISSION_MATRIX: readonly {
}[] = [
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入)', admin: true, head: false, subject: false },
{ action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false },
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
{ action: '布置作业', admin: true, head: true, subject: true },
+38
View File
@@ -1098,3 +1098,41 @@ export interface DatabaseSwitchStepDto {
status: 'running' | 'done' | 'error';
detail?: string;
}
/* ------------------------------------------------------------------ 更新检查 */
/** Release 里的一个附件(安装包 / 便携版 / 插件包) */
export interface UpdateAssetDto {
name: string;
downloadUrl: string;
size: number;
}
/**
* 更新检查结果(服务端 `GET /api/update/check` 与桌面客户端 IPC 共用同一结构)。
*
* 两点约定:
* 1. **`ok:false` 是正常结果,不是异常** —— 服务器没有外网、GitHub 限流、请求超时都会走到这里,
* 调用方按"暂时查不到"呈现即可(与 ClassIsland 推送失败只记日志是同一种处理思路)。
* 2. **`currentVersion` 由调用方各自提供**:服务端比的是服务端版本、客户端比的是客户端版本。
* 两边安装版本可能不同(例如教室机没跟着升级),所以不能由一端替另一端判断。
*/
export interface UpdateInfo {
ok: boolean;
/** 失败原因(给人看的一句话);ok=true 时为 null */
error: string | null;
/** 本机版本 */
currentVersion: string;
/** GitHub 上最新 Release 的版本号;ok=false 时为 null */
latestVersion: string | null;
hasUpdate: boolean;
/** 给人看的 Release 页面地址(「前往下载」用) */
releaseUrl: string;
/** Release 标题 */
releaseName: string | null;
publishedAt: string | null;
/** Release 说明(Markdown 原文,由界面自行截断展示) */
notes: string | null;
assets: UpdateAssetDto[];
checkedAt: string;
}
+63 -3
View File
@@ -28,9 +28,7 @@ export function isDayKey(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
return (
date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
}
/** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */
@@ -357,3 +355,65 @@ export function minutesUntil(time: string, now: Date = new Date()): number {
if (target < 0) return 0;
return target - (now.getHours() * 60 + now.getMinutes());
}
/* ------------------------------------------------------------------ 版本比较(更新检查) */
/**
* 版本号归一化:去掉首尾空白与前导的 `v` / `V`。
*
* 之所以需要:GitHub 的 tag 是 `v1.0.0`,而 package.json / `app.getVersion()` 给的是 `1.0.0`,
* 两边不归一化就没法比。三端共用这一个实现,避免"客户端说有新版、服务端说没有"。
*/
export function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^[vV]/, '');
}
/**
* 比较两个版本号:`a` 比 `b` 新返回正数,旧返回负数,相同返回 0。
*
* 规则:
* 1. 按 `.` 分段比数字(每段只取开头的连续数字,因此 `1.0.0-fix` 的第三段仍是 `0`);
* 2. 数字全部相同、只有后缀不同时,**带后缀的一方视为更新**。
*
* 第 2 条是为 `v0.1.0-fix` 这种"同一个版本号的修复发布"定的:按标准 semver,
* `0.1.0-fix` 是 `0.1.0` 的**预发布**(应当更旧),但本项目是拿它当"修完再补发一版"用的,
* 照 semver 判会让那条修复版永远提示不出来。另外 `/releases/latest` 本身会跳过预发布,
* 所以不用担心把 `-beta` 当成正式版。
*/
export function compareVersions(a: string | null | undefined, b: string | null | undefined): number {
const left = normalizeVersion(a);
const right = normalizeVersion(b);
if (left === right) return 0;
const parse = (value: string): { numbers: number[]; suffix: string } => {
const [core = '', ...rest] = value.split('-');
const numbers = core.split('.').map((part) => {
const matched = /^\d+/.exec(part.trim());
return matched ? Number(matched[0]) : 0;
});
return { numbers, suffix: rest.join('-') };
};
const l = parse(left);
const r = parse(right);
const length = Math.max(l.numbers.length, r.numbers.length);
for (let index = 0; index < length; index += 1) {
const diff = (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
if (l.suffix === r.suffix) return 0;
// 数字相同时:裸版本更旧(1.0.0 < 1.0.0-fix),两个都有后缀才按字典序
if (!l.suffix) return -1;
if (!r.suffix) return 1;
return l.suffix.localeCompare(r.suffix);
}
/** `latest` 是否比 `current` 新(更新检查的判定入口;latest 为空一律视为"没有新版") */
export function isNewerVersion(
current: string | null | undefined,
latest: string | null | undefined,
): boolean {
if (!normalizeVersion(latest)) return false;
return compareVersions(latest, current) > 0;
}
+2 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@classhelper/web-admin",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"build": "node ../../scripts/check-icons.mjs && vue-tsc --noEmit -p tsconfig.json && vite build",
"build:only": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
@@ -15,7 +15,6 @@
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
+22 -8
View File
@@ -42,6 +42,7 @@ import {
type TimeLayoutParsePreview,
type UpdateClassAccountRequest,
type UpdateClassRequest,
type UpdateInfo,
type UpdateIntegrationDeviceRequest,
type UserDto,
} from '@classhelper/shared';
@@ -126,12 +127,8 @@ export const homeworkApi = {
date?: string;
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
/** 哪些天有作业(日期选择器高亮) */
days: (params: {
classId?: string;
from?: string;
to?: string;
days?: number;
}): Promise<HomeworkDaysDto> => api.get(`${API_PATHS.homeworks}/days`, params),
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
api.get(`${API_PATHS.homeworks}/days`, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
@@ -344,8 +341,10 @@ export const integrationApi = {
*/
export const databaseApi = {
status: (): Promise<DatabaseStatusDto> => api.get(`${API_PATHS.database}/status`),
testConnection: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise<DatabaseConnectionTestDto> =>
api.post(`${API_PATHS.database}/test-connection`, payload),
testConnection: (payload: {
provider: 'sqlite' | 'mysql';
url: string;
}): Promise<DatabaseConnectionTestDto> => api.post(`${API_PATHS.database}/test-connection`, payload),
listBackups: (): Promise<DatabaseBackupMetaDto[]> => api.get(`${API_PATHS.database}/backups`),
createBackup: (): Promise<DatabaseBackupMetaDto> => api.post(`${API_PATHS.database}/backups`, {}),
restoreBackup: (name: string): Promise<{ counts: Record<string, number> }> =>
@@ -366,3 +365,18 @@ export const databaseApi = {
getSwitchJob: (jobId: string): Promise<DatabaseSwitchJobDto | null> =>
api.get(`${API_PATHS.database}/switch/jobs/${encodeURIComponent(jobId)}`),
};
/**
* 更新检查:问服务端 GitHub 上有没有比本机新的版本。
*
* **Web 端不直连 GitHub** —— 服务端下发的 CSP `connect-src` 只放行 `'self'` 与 WebSocket
* (见 `middleware/security.ts`),浏览器直连 `api.github.com` 会被拦掉;
* 走服务端转发还顺带让所有管理员共享同一份缓存(匿名限流 60 次/小时/IP)。
*
* `ok:false` 是正常结果(机房没外网 / GitHub 限流),不是异常。
*/
export const updateApi = {
/** @param force 绕过服务端缓存;仅管理员生效 */
check: (force = false): Promise<UpdateInfo> =>
api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`),
};
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed } from 'vue';
/**
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
*
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
* 而 `import * as echarts from 'echarts'` 会给构建产物加 1.1MB。
*
* 实现要点(决定了这里为什么不需要 resize 监听):
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
* 柱子的 `height: X%` 直接按它解析;
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 28px),因此永远对齐。
*
* 与桌面客户端那份(`packages/desktop-client/src/renderer/components/ScoreBarChart.vue`)
* 是刻意分开的两份:两端的设计令牌体系不同(`--ch-*` 浅/深色 vs `--el-*`),
* 合用一个组件反而要塞一堆条件样式。
*/
interface BarItem {
label: string;
value: number;
}
const props = withDefaults(
defineProps<{
/** 每根柱:分类名 + 数值 */
items: BarItem[];
/** 柱色 */
color?: string;
/** 纵轴上限;不传时按数据最大值向上取整到「好看」的刻度 */
max?: number;
/** 数值单位(显示在刻度与柱顶数值后面) */
unit?: string;
/** 图表高度(px) */
height?: number;
}>(),
{ color: '#409eff', max: undefined, unit: '', height: 220 },
);
/** 把数值向上取整到 1/2/5 × 10^n,让刻度落在整数上 */
function niceCeil(value: number): number {
if (value <= 0) return 1;
const base = 10 ** Math.floor(Math.log10(value));
const normalized = value / base;
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return step * base;
}
const axisMax = computed(() => {
if (props.max !== undefined) return props.max;
const dataMax = props.items.reduce((max, item) => Math.max(max, item.value), 0);
return niceCeil(dataMax);
});
/** 纵轴刻度(自上而下):100% / 75% / 50% / 25% / 0%,去重后最多 5 条 */
const ticks = computed(() => {
const values = [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(axisMax.value * ratio));
return [...new Set(values)];
});
function barHeight(value: number): string {
const ratio = axisMax.value > 0 ? value / axisMax.value : 0;
return `${Math.min(1, Math.max(0, ratio)) * 100}%`;
}
function formatValue(value: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
}
</script>
<template>
<div class="bar-chart" :style="{ height: `${height}px` }">
<div v-if="items.length === 0" class="bar-chart-empty">暂无数据</div>
<template v-else>
<div class="axis-y" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
</div>
<div class="plot">
<div class="gridlines" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
</div>
<div class="bars">
<div v-for="item in items" :key="item.label" class="bar-col">
<div class="bar-value">{{ formatValue(item.value) }}</div>
<div class="bar-track">
<div
class="bar"
:style="{ height: barHeight(item.value), background: color }"
:title="`${item.label}:${formatValue(item.value)}`"
></div>
</div>
<div class="bar-label" :title="item.label">{{ item.label }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.bar-chart {
display: grid;
grid-template-columns: 40px 1fr;
width: 100%;
}
.bar-chart-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.axis-y {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 0 28px;
}
.tick {
height: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
color: var(--el-text-color-secondary);
}
.plot {
position: relative;
min-width: 0;
}
.gridlines {
position: absolute;
inset: 18px 0 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.gridline {
height: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
.bars {
display: flex;
align-items: stretch;
gap: 8px;
height: 100%;
padding: 0 6px;
}
.bar-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.bar-value {
flex: none;
height: 18px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
color: var(--el-text-color-secondary);
}
.bar-track {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 46%;
max-width: 46px;
border-radius: 4px 4px 0 0;
transition: height 0.2s ease;
}
.bar-label {
flex: none;
height: 28px;
padding-top: 6px;
font-size: 11px;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { computed } from 'vue';
/**
* 轻量折线图(内联 SVG + HTML 数据点,不依赖任何图表库)。
*
* 为什么不用 echarts:成绩页只需要一条「各科平均得分率」折线,
* 而 `import * as echarts from 'echarts'` 会给构建产物加 1.1MB。
*
* 实现要点:
* - 线画在 `viewBox="0 0 100 100"` + `preserveAspectRatio="none"` 的 SVG 里,
* 于是 viewBox 坐标 (x, y) 精确对应绘图区的 (x%, y%);`vector-effect="non-scaling-stroke"`
* 保证非等比拉伸时线宽仍是 2px;
* - 数据点用**绝对定位的 HTML 圆点**(而不是 SVG `<circle>`),这样非等比拉伸下仍是正圆;
* 点位与线的坐标共用同一套百分比,天然对齐;
* - 横轴分类点落在**每个分组的中心**(`(i + 0.5) / n`),与 echarts 的 category 轴一致;
* - 全程零 resize 监听。
*/
interface LineItem {
label: string;
value: number;
}
const props = withDefaults(
defineProps<{
items: LineItem[];
color?: string;
/** 纵轴上限;不传时按数据最大值向上取整 */
max?: number;
unit?: string;
height?: number;
}>(),
{ color: '#67c23a', max: undefined, unit: '%', height: 220 },
);
function niceCeil(value: number): number {
if (value <= 0) return 1;
const base = 10 ** Math.floor(Math.log10(value));
const normalized = value / base;
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return step * base;
}
const axisMax = computed(() => {
if (props.max !== undefined) return props.max;
const dataMax = props.items.reduce((max, item) => Math.max(max, item.value), 0);
return niceCeil(dataMax);
});
const ticks = computed(() => {
const values = [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(axisMax.value * ratio));
return [...new Set(values)];
});
/** 数据点:x / y 都是绘图区的百分比坐标(0~100) */
const points = computed(() => {
const count = props.items.length;
const max = axisMax.value;
return props.items.map((item, index) => {
const ratio = max > 0 ? item.value / max : 0;
return {
label: item.label,
value: item.value,
x: ((index + 0.5) / count) * 100,
y: (1 - Math.min(1, Math.max(0, ratio))) * 100,
};
});
});
/**
* Catmull-Rom 转三次贝塞尔,复刻 echarts `smooth: true` 的观感。
* tension 0.2 与 echarts 的默认平滑强度接近。
*/
const linePath = computed(() => {
const list = points.value;
if (list.length < 2) return '';
const tension = 0.2;
const round = (value: number) => Number(value.toFixed(2));
let path = `M ${round(list[0]!.x)} ${round(list[0]!.y)}`;
for (let index = 0; index < list.length - 1; index += 1) {
const previous = list[index - 1] ?? list[index]!;
const current = list[index]!;
const next = list[index + 1]!;
const afterNext = list[index + 2] ?? next;
const control1x = current.x + (next.x - previous.x) * tension;
const control1y = current.y + (next.y - previous.y) * tension;
const control2x = next.x - (afterNext.x - current.x) * tension;
const control2y = next.y - (afterNext.y - current.y) * tension;
path += ` C ${round(control1x)} ${round(control1y)}, ${round(control2x)} ${round(control2y)}, ${round(
next.x,
)} ${round(next.y)}`;
}
return path;
});
/** 折线下方的面积:从终点垂到基线,再沿基线回到起点 */
const areaPath = computed(() => {
const list = points.value;
if (list.length < 2) return '';
const first = list[0]!;
const last = list[list.length - 1]!;
return `${linePath.value} L ${Number(last.x.toFixed(2))} 100 L ${Number(first.x.toFixed(2))} 100 Z`;
});
function formatValue(value: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
}
</script>
<template>
<div class="line-chart" :style="{ height: `${height}px` }">
<div v-if="items.length === 0" class="line-chart-empty">暂无数据</div>
<template v-else>
<div class="axis-y" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
</div>
<div class="plot">
<div class="gridlines" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
</div>
<svg class="lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<path v-if="areaPath" :d="areaPath" :fill="color" fill-opacity="0.12" />
<path
v-if="linePath"
:d="linePath"
fill="none"
:stroke="color"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="points">
<div v-for="point in points" :key="point.label" class="point-col">
<span
class="point"
:style="{ top: `${point.y}%`, background: color }"
:title="`${point.label}:${formatValue(point.value)}`"
></span>
</div>
</div>
<div class="x-labels" aria-hidden="true">
<span v-for="point in points" :key="point.label" class="x-label" :title="point.label">
{{ point.label }}
</span>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.line-chart {
display: grid;
grid-template-columns: 40px 1fr;
width: 100%;
}
.line-chart-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
/* 上下留白与绘图区(.gridlines / .lines / .points 的 inset)严格一致 */
.axis-y {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 0 28px;
}
.tick {
height: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
color: var(--el-text-color-secondary);
}
.plot {
position: relative;
min-width: 0;
}
.gridlines,
.lines,
.points {
position: absolute;
inset: 18px 0 28px;
}
.gridlines {
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.gridline {
height: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
.lines {
/* 尺寸完全由上面的 inset 决定(left/right/top/bottom 都给了),不要再写 width/height,
否则会和 inset 打架 */
pointer-events: none;
}
.points {
display: flex;
}
.point-col {
flex: 1 1 0;
min-width: 0;
position: relative;
}
.point {
position: absolute;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.x-labels {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 28px;
display: flex;
pointer-events: none;
}
.x-label {
flex: 1 1 0;
min-width: 0;
padding-top: 6px;
font-size: 11px;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { formatDate, type UpdateInfo } from '@classhelper/shared';
import { updateApi } from '@/api';
/**
* 「检查更新」弹窗(Web 管理端顶栏用户菜单)。
*
* 数据来自**服务端**的 `GET /api/update/check` —— 浏览器受服务端下发的 CSP `connect-src`
* 限制,不能直连 `api.github.com`;走服务端转发还顺带让所有管理员共享同一份缓存
* (GitHub 匿名限流 60 次/小时/IP)。
*
* `ok:false` 是**正常结果**(机房没有外网 / GitHub 限流 / 超时),不是错误 ——
* 界面按"暂时查不到"呈现,并给出重试入口,而不是弹一句红字报错。
*/
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ (event: 'update:modelValue', value: boolean): void }>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const loading = ref(false);
const info = ref<UpdateInfo | null>(null);
/** 发布时间(本地格式),拿不到就不显示 */
const publishedText = computed(() => {
const at = info.value?.publishedAt;
return at ? formatDate(at, true) : '';
});
/**
* 用户点菜单里的「检查更新」是一次明确的手动检查,因此**绕过服务端缓存**(force=1,仅管理员生效);
* 打开弹窗时才查,而不是进页面就查 —— 没点菜单就不该产生任何外部请求。
*/
async function check(): Promise<void> {
loading.value = true;
try {
info.value = await updateApi.check(true);
} catch {
// 请求本身失败(如火并发的接口限流):http.ts 已经弹过提示,这里只保证弹窗里不留空白
info.value = null;
} finally {
loading.value = false;
}
}
function openRelease(): void {
const url = info.value?.releaseUrl;
if (!url) return;
window.open(url, '_blank', 'noopener');
}
function formatBytes(size: number): string {
if (!Number.isFinite(size) || size <= 0) return '';
const mb = size / 1024 / 1024;
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(size / 1024)} KB`;
}
// 打开时才查:关闭后清空结果,下次打开重新查一遍(避免显示上一次的陈旧结论)
watch(
() => props.modelValue,
(opened) => {
if (opened) void check();
else info.value = null;
},
);
</script>
<template>
<el-dialog v-model="visible" title="检查更新" width="520px" data-test="update-dialog">
<div v-loading="loading" class="update-body">
<template v-if="info && info.ok">
<el-alert
v-if="info.hasUpdate"
type="success"
:closable="false"
show-icon
data-test="update-found"
:title="`发现新版本 v${info.latestVersion}`"
:description="`当前版本 v${info.currentVersion}${publishedText ? ` · 发布于 ${publishedText}` : ''}`"
/>
<el-alert
v-else
type="info"
:closable="false"
show-icon
data-test="update-latest"
title="已是最新版本"
:description="`当前版本 v${info.currentVersion}(GitHub 最新为 v${info.latestVersion})`"
/>
<template v-if="info.hasUpdate">
<div v-if="info.releaseName" class="update-name">{{ info.releaseName }}</div>
<div v-if="info.notes" class="update-notes-label">更新说明</div>
<pre v-if="info.notes" class="update-notes">{{ info.notes }}</pre>
<template v-if="info.assets.length > 0">
<div class="update-notes-label">可下载文件</div>
<ul class="update-assets">
<li v-for="asset in info.assets" :key="asset.name">
<span class="update-asset-name">{{ asset.name }}</span>
<span v-if="formatBytes(asset.size)" class="update-asset-size">
{{ formatBytes(asset.size) }}
</span>
</li>
</ul>
</template>
</template>
</template>
<!-- 查不到不是错误:机房没有外网、GitHub 限流都会走到这里 -->
<el-alert
v-else-if="!loading"
type="warning"
:closable="false"
show-icon
data-test="update-unavailable"
title="暂时无法检查更新"
:description="info?.error ?? '请稍后重试;若服务器没有外网,可手动打开项目 Release 页面查看'"
/>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button :loading="loading" @click="check()">重新检查</el-button>
<el-button v-if="info?.ok && info.hasUpdate" type="primary" @click="openRelease">前往下载</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.update-body {
min-height: 72px;
}
.update-name {
margin-top: 12px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.update-notes-label {
margin: 12px 0 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.update-notes {
margin: 0;
padding: 10px 12px;
max-height: 200px;
overflow: auto;
background: var(--el-fill-color-light);
border-radius: var(--ch-radius-sm);
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
color: var(--el-text-color-regular);
}
.update-assets {
margin: 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.9;
color: var(--el-text-color-regular);
}
.update-asset-size {
margin-left: 6px;
color: var(--el-text-color-secondary);
}
</style>
+74
View File
@@ -0,0 +1,74 @@
import type { App } from 'vue';
import {
ArrowDown,
Bell,
Calendar,
Coin,
Connection,
CopyDocument,
Document,
Guide,
Lock,
Menu,
Notebook,
Odometer,
Plus,
Refresh,
School,
Search,
SwitchButton,
Trophy,
Upload,
User,
UserFilled,
WarningFilled,
} from '@element-plus/icons-vue';
/**
* Element Plus 图标注册表(Web 管理端)。
*
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 22 个 / 59KB),
* 全量注册既把 144KB 死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
*
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
* (菜单/路由配置里的 `icon: 'Odometer'`、按钮上的 `:icon="'Search'"`、
* `<component :is="item.icon">`),Element Plus 的 `el-button` / `el-menu-item`
* 会用 `<component :is="'Odometer'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
*
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错(见 package.json 的 build 脚本),
* 不会静默渲染成空白。
*/
const APP_ICONS = {
ArrowDown,
Bell,
Calendar,
Coin,
Connection,
CopyDocument,
Document,
Guide,
Lock,
Menu,
Notebook,
Odometer,
Plus,
Refresh,
School,
Search,
SwitchButton,
Trophy,
Upload,
User,
UserFilled,
WarningFilled,
} as const;
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort();
export function registerAppIcons(app: App): void {
for (const [name, component] of Object.entries(APP_ICONS)) {
app.component(name, component);
}
}
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { STORAGE_KEYS } from '@classhelper/shared';
import { authApi } from '@/api';
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
@@ -107,6 +108,10 @@ async function handleLogout(): Promise<void> {
void router.push({ name: 'login' });
}
/* ------------------------------------------------------------ 检查更新 */
/** 顶栏用户菜单「检查更新」弹窗 */
const updateVisible = ref(false);
/* ------------------------------------------------------------ 修改密码 */
const passwordVisible = ref(false);
const passwordFormRef = ref<FormInstance>();
@@ -295,6 +300,10 @@ const tourSteps = computed(() => [
<el-icon><Guide /></el-icon>
使用引导
</el-dropdown-item>
<el-dropdown-item divided data-test="update-check-entry" @click="updateVisible = true">
<el-icon><Refresh /></el-icon>
检查更新
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
@@ -352,6 +361,9 @@ const tourSteps = computed(() => [
</template>
</el-dialog>
<!-- 检查更新:问服务端最新 Release(浏览器受 CSP 限制不能直连 GitHub),见组件内说明 -->
<UpdateCheckDialog v-model="updateVisible" />
<!--
新手引导:首次登录自动弹出(见 script 的 onMounted),头像菜单「使用引导」可重看。
target 为 null 的步骤(首尾两步)由 el-tour 放到屏幕居中;其余步骤锚定真实 UI。
+3 -5
View File
@@ -2,19 +2,17 @@ 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';
import { registerAppIcons } from './icons';
import '@/styles/index.css';
const app = createApp(App);
// 全量注册 Element Plus 图标,模板里可直接 <el-icon><Bell /></el-icon>
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
registerAppIcons(app);
app.use(createPinia());
app.use(router);
+22 -68
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
SUBJECT_CATALOG,
@@ -15,6 +14,8 @@ import {
type StudentDto,
} from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api';
import ScoreBarChart from '@/components/ScoreBarChart.vue';
import ScoreLineChart from '@/components/ScoreLineChart.vue';
import TableImportDialog from '@/components/TableImportDialog.vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
@@ -41,10 +42,15 @@ const students = ref<StudentDto[]>([]);
const stats = ref<GradeStats | null>(null);
const filter = reactive({ classId: '', courseId: '', examName: '' });
const levelChartRef = ref<HTMLDivElement>();
const courseChartRef = ref<HTMLDivElement>();
const levelChart = ref<ReturnType<typeof echarts.init> | null>(null);
const courseChart = ref<ReturnType<typeof echarts.init> | null>(null);
/** 等级分布柱状图的数据(count 是整数,纵轴上限交给组件按数据自动取整) */
const levelItems = computed(() =>
(stats.value?.distribution ?? []).map((item) => ({ label: `${item.level} 等`, value: item.count })),
);
/** 各课程平均得分率折线图的数据 */
const courseItems = computed(() =>
(stats.value?.byCourse ?? []).map((item) => ({ label: item.courseName, value: item.averagePercent })),
);
const averagePercent = computed(() => {
if (grades.value.length === 0) return 0;
@@ -79,7 +85,6 @@ async function loadGrades(): Promise<void> {
if (requestId !== gradesRequestId) return;
grades.value = list;
stats.value = statistic;
renderCharts();
} finally {
if (requestId === gradesRequestId) loading.value = false;
}
@@ -94,55 +99,15 @@ async function onClassChange(): Promise<void> {
await loadTeacherSuggestions();
}
/* ------------------------------------------------------------ 图表 */
function renderCharts(): void {
const statistic = stats.value;
if (!statistic) return;
if (levelChartRef.value) {
levelChart.value ??= echarts.init(levelChartRef.value);
levelChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.distribution.map((item) => `${item.level} 等`) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'bar',
barWidth: '46%',
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
data: statistic.distribution.map((item) => item.count),
},
],
});
}
if (courseChartRef.value) {
courseChart.value ??= echarts.init(courseChartRef.value);
courseChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.byCourse.map((item) => item.courseName) },
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'line',
smooth: true,
symbolSize: 8,
areaStyle: { opacity: 0.12 },
itemStyle: { color: '#67c23a' },
data: statistic.byCourse.map((item) => item.averagePercent),
},
],
});
}
}
function handleResize(): void {
levelChart.value?.resize();
courseChart.value?.resize();
}
/* ------------------------------------------------------------ 图表
*
* 等级分布与各课程得分率原先用 echarts 渲染(`import * as echarts from 'echarts'`
* 会给构建产物加 1.1MB)。现在换成两个自包含的轻量组件:
* - ScoreBarChart:纯 HTML/CSS 柱状图
* - ScoreLineChart:内联 SVG 折线 + HTML 数据点
* 两者都是响应式的(百分比布局),因此不再需要 resize 监听,
* 数据也直接从上面两个 computed 流过去,不需要手写 setOption。
*/
/* ------------------------------------------------------------ 统一科目
*
@@ -299,14 +264,10 @@ watch(
onMounted(async () => {
await loadClasses();
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
levelChart.value?.dispose();
courseChart.value?.dispose();
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
</script>
@@ -361,13 +322,13 @@ onUnmounted(() => {
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>等级分布</span></template>
<div ref="levelChartRef" class="chart"></div>
<ScoreBarChart :items="levelItems" />
</el-card>
</el-col>
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>各课程平均得分率</span></template>
<div ref="courseChartRef" class="chart"></div>
<ScoreLineChart :items="courseItems" :max="100" />
</el-card>
</el-col>
</el-row>
@@ -503,10 +464,3 @@ onUnmounted(() => {
/>
</div>
</template>
<style scoped>
.chart {
height: 220px;
width: 100%;
}
</style>