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:
1 parent
aad22df4fd
commit
9a38f86cd3
77 files changed
+8021
-899
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user