Files
ClassHelper/website/assets/download.js
T
LoveChengke 0ea6bd887f feat(website,docs,build): 官网加一个下载页(挑版本装机),文档站的照片换成与 README 同一份
下载页(`website/download.html` + `assets/download.js`)

- 官网首页的「立即下载」、顶栏与页脚的「取安装包」全部指向新页 `/download.html`。
  那一页是「先挑一个版本,再挑要装的那一份」:一个版本分段控件 + 四张卡
  (班级端 / 服务端 Windows / 服务端 Linux / ClassIsland 插件),每张卡列出这一版里
  真实存在的文件、体积与直链;
- 版本与文件清单**不在站点里存副本**,每次打开都由 `assets/download.js` 当场读 GitHub
  Releases —— 文件名、体积、链接照抄发布页,不靠命名约定去猜;
- 读不到时(离线,或匿名接口 60 次/小时按出口 IP 被限流)退回页面上那份
  `<meta name="ch-version">`,按发布命名约定拼直链,并在页面上**明说这是拼出来的**;
- 老版本缺哪个包就显示「这一版没有发布…」(`v0.1.0-fix` 只有插件包,实测正确);
  Linux 那一行安装命令跟着选中版本钉上 `--version`(tag 不是 `x.y.z` 时不带);
- 动态建出来的卡片与文件行派发 `ch:content`,由 `main.js` 补挂按压反馈
  —— 这些元素是拉到清单之后才出现的,启动时还看不到它们。

文档站的照片(`docs/images/`)

- 页面里的图一律从 `docs/screenshots/**` 换成 `docs/images/**`:后者就是 README 与官网
  吃的那一份(字节相同),前者是 9-26 的冒烟留档,比 README 落后一个版本;
- 展示图与回归留档这两套图的分工写进 `docs/dev/testing.md`「截图留档」,含重新采集命令
  (冒烟会把「岛外留白对照图」落在同一个目录,所以要先落临时目录再挑);
- 顺带修 README 手机截图那张标错的图注(`mobile-dashboard.jpg` 是仪表盘,不是抽屉菜单);
- `docs/reference.md` 验收表里那张 ClassIsland「真机实测截图」不再嵌图:它是连着桌面、
  任务栏和另一个窗口整屏截下来的,当文档配图不能用。断言与用例名都保留,另外注明了抓图位置。

版本落点

- `scripts/version.mjs` 增加 `website/download.html` 的兜底版本号,落点 13 → 14 处
  (`AGENTS.md` §4.4 与 `docs/dev/release.md` 同步);
- README「开始使用」表里那三个文件名改成**发布页上真实的名字**
  (`-x64-client-setup` / `-x64-client-portable` / `-x64-server-setup`)—— 原来写的是构建
  产物的名字;「上传前把产物改成 ASCII 名」这一步也一并写进发布流程,
  它是官网下载页离线兜底与 README 表格的共同依据。
2026-10-06 18:37:42 +08:00

435 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ClassHelper · 下载页(download.html)
*
* 这一页只做一件事:**挑一个版本,挑一份文件,点下去就下**。
* 版本与文件清单有两种来路,按可靠性兜底:
*
* ① 当场读 GitHub Releases 接口 —— 拿到的就是发布页上真实存在的资源,
* 文件名与体积照抄,不靠命名约定去猜(这是正常路径);
* ② 读不到时(离线、被限流、被墙)退回 HTML 里那份 `<meta name="ch-version">`,
* 按**发布的命名约定**拼直链,并在页面上明说"这是拼出来的"。
*
* 为什么不把清单预生成成 JSON 放进站点:那样只在部署那一刻是新的,
* 发了新版本得等下一次推 master 才更新。而这一页几乎每一步都指回 github.com,
* 真离线时它本来也用不了 —— 所以真正要兜的只有"接口被限流",
* 那由 ② 兜住就够了。匿名接口每小时 60 次、按出口 IP 算,机房出口很容易撞到。
*
* 动效沿用 assets/motion.js 那套 beUI token(版本分段控件的指示器与首页实机截图那组同源)。
* 建完卡片会派发一次 `ch:content`,让 main.js 把按压反馈补挂上去
* —— 这些元素是拉到清单之后才出现的,main.js 启动时还看不到它们。
*/
(() => {
'use strict';
const REPO = 'LoveChengke/ClassHelper';
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
const RELEASES_PAGE = `https://github.com/${REPO}/releases`;
/** 版本选择器最多列几版;更早的去 Releases 页看 */
const MAX_VERSIONS = 12;
const motion = window.CHMotion ?? null;
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
/** 页面上那份兜底版本号,由 `pnpm version:bump` 与根 package.json 一起维护 */
const FALLBACK_VERSION = ($('meta[name="ch-version"]')?.content ?? '').trim();
/**
* 四个交付物 = 四张卡;卡里每一行 = Release 上的一个文件。
*
* `match` 用来在真实资源里认领这个文件,`expect` 在没有接口时按命名约定拼直链的名字。
* **两个都要跟着发布流程一起改** —— 名字口径与 README「开始使用」那张表、
* `.cache/release-notes-*.md` 里那份清单是同一套。
*/
const CARDS = [
{
key: 'client',
who: '教室机器 / 学生电脑',
name: 'ClassHelper 班级端',
desc: '第一次打开时填服务器地址和班级码,之后开机就能看课表、作业、通知与成绩;断网时读本地缓存,恢复后自己刷新。',
files: [
{
role: '安装版',
note: 'NSIS 安装程序,装完在开始菜单里',
match: /-client-setup\.exe$/i,
expect: (version) => `ClassHelper-${version}-x64-client-setup.exe`,
},
{
role: '单文件版',
note: '不用安装,放 U 盘里就能分发',
match: /-client-portable\.exe$/i,
expect: (version) => `ClassHelper-${version}-x64-client-portable.exe`,
},
],
},
{
key: 'server',
who: '教师电脑 / 学校服务器 · Windows',
name: 'ClassHelper 服务端',
desc: '内含 Web 管理端与 Node 运行时,装上就算跑起来:建库、建号、开机自启都由它自己办。',
files: [
{
role: '安装程序',
note: '装完浏览器打开 http://<服务器地址>:4000',
match: /-server-setup\.exe$/i,
expect: (version) => `ClassHelper-${version}-x64-server-setup.exe`,
},
],
},
{
key: 'linux',
who: 'Linux 服务器',
name: '服务端 · systemd',
desc: '一行命令装完,附带 classhelper 运维命令(status / upgrade / backup / doctor / password);也可以下压缩包自己解。',
files: [
{
role: '安装包',
note: '解压即用,含内置 Node 运行时',
match: /^classhelper-server-linux-x64-.*\.tar\.gz$/i,
expect: (version) => `classhelper-server-linux-x64-${version}.tar.gz`,
},
{
role: '校验值',
note: '安装脚本会自动取它核对',
match: /^classhelper-server-linux-x64-.*\.tar\.gz\.sha256$/i,
expect: (version) => `classhelper-server-linux-x64-${version}.tar.gz.sha256`,
},
],
},
{
key: 'plugin',
who: '教室大屏 · 装在 ClassIsland 上',
name: 'ClassIsland 联动插件',
desc: '把课表上报给服务端,把老师发的提醒弹到 ClassIsland 的全屏提醒上。上课时段的普通通知先收着,打下课铃补弹。',
files: [
{
role: '插件包',
note: '在 ClassIsland 的插件页里选它安装',
match: /classislandplugin\.cipx$/i,
expect: () => 'ClassHelper.ClassIslandPlugin.cipx',
},
],
},
];
const tabs = $('#ver-tabs');
const indicator = $('#ver-indicator');
const note = $('#ver-note');
const grid = $('#dl-grid');
const gridNote = $('#dl-note');
const primary = $('#ver-primary');
const command = $('#cmd-linux code');
if (!tabs || !indicator || !note || !grid || !gridNote || !primary || !command) return;
/** 当前选中的版本清单来自哪儿:live(读到的) / fallback(拼出来的) */
let source = 'live';
let releases = [];
let current = null;
/* ══════════════════════════════════════════════════════════════════════
小工具
══════════════════════════════════════════════════════════════════════ */
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
/** 体积口径与 GitHub 发布页一致(1024 进制,标签写 MB) */
function formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
/* ══════════════════════════════════════════════════════════════════════
取清单
══════════════════════════════════════════════════════════════════════ */
async function fetchReleases() {
const response = await fetch(RELEASES_API, { headers: { accept: 'application/vnd.github+json' } });
if (!response.ok) throw new Error(`GitHub 返回 ${response.status}`);
const list = await response.json();
if (!Array.isArray(list)) throw new Error('返回的不是发布列表');
return list
.filter((item) => item && item.tag_name && !item.draft)
.map((item) => ({
tag: String(item.tag_name),
version: String(item.tag_name).replace(/^v/i, ''),
prerelease: Boolean(item.prerelease),
publishedAt: String(item.published_at ?? '').slice(0, 10),
notesUrl: item.html_url ?? `${RELEASES_PAGE}/tag/${item.tag_name}`,
assets: (item.assets ?? []).map((asset) => ({
name: String(asset.name ?? ''),
url: String(asset.browser_download_url ?? ''),
size: Number(asset.size) || 0,
})),
}))
.slice(0, MAX_VERSIONS);
}
/** 最新的一版 = 第一个正式版(预发布不占这个位置) */
function markLatest(list) {
const index = list.findIndex((item) => !item.prerelease);
if (index >= 0) list[index].latest = true;
return list;
}
/** 接口读不到时的替身:版本号取自页面上的 meta,文件名按发布命名约定拼 */
function fallbackRelease() {
const version = FALLBACK_VERSION || '0.0.0';
const tag = `v${version}`;
const assets = [];
for (const card of CARDS) {
for (const file of card.files) {
const name = file.expect(version);
assets.push({ name, url: `${RELEASES_PAGE}/download/${tag}/${name}`, size: 0 });
}
}
return {
tag,
version,
latest: true,
prerelease: false,
publishedAt: '',
notesUrl: `${RELEASES_PAGE}/tag/${tag}`,
assets,
};
}
/* ══════════════════════════════════════════════════════════════════════
版本分段控件 —— beUI Tabs:一个指示器在选项之间滑移
══════════════════════════════════════════════════════════════════════ */
let setIndicator = null;
if (motion) {
const x = new motion.Spring(0, motion.SPRING_LAYOUT, (value) => {
indicator.style.transform = `translateX(${motion.round2(value)}px)`;
});
const width = new motion.Spring(0, motion.SPRING_LAYOUT, (value) => {
indicator.style.width = `${motion.round2(value)}px`;
});
setIndicator = (left, size, animate) => {
if (!animate || motion.reduceMotion()) {
x.jump(left);
width.jump(size);
return;
}
// 首次落位时宽度弹簧还在 0,直接跳过去,免得从 0 撑开
if (width.value === 0) width.jump(size);
x.set(left);
width.set(size);
};
} else {
// 动效层没加载出来也要能用:直接落位
setIndicator = (left, size) => {
indicator.style.transform = `translateX(${left}px)`;
indicator.style.width = `${size}px`;
};
}
/**
* 位置用 offsetLeft / offsetWidth 量:指示器是绝对定位在 `.dl-tabs`(position: relative)里的,
* 两者的参照边都是容器的内边距边 —— 而且这样**与容器的横向滚动无关**
* (版本多了以后这排是可以横滚的,用 getBoundingClientRect 会在滚动后对不上)。
*/
function moveIndicator(tag, animate) {
const button = $$('.dl-tab', tabs).find((node) => node.dataset.tag === tag);
if (!button) return;
setIndicator(button.offsetLeft, button.offsetWidth, animate);
}
function renderTabs() {
$$('.dl-tab', tabs).forEach((node) => node.remove());
for (const release of releases) {
const button = element('button', 'dl-tab');
button.type = 'button';
button.setAttribute('role', 'tab');
button.dataset.tag = release.tag;
button.dataset.press = '0.96';
button.setAttribute('aria-selected', 'false');
button.append(element('span', null, release.tag));
if (release.latest) button.append(element('span', 'dl-tab-flag', '最新'));
else if (release.prerelease) button.append(element('span', 'dl-tab-flag dl-tab-flag-quiet', '预发布'));
button.addEventListener('click', () => select(release.tag));
tabs.append(button);
}
// 首帧之后重量一次:楷体是 CDN 来的,字体落定前量出来的宽度是错的
moveIndicator(current?.tag ?? releases[0]?.tag, false);
if (motion) {
motion.nextFrame(() => moveIndicator(current?.tag ?? releases[0]?.tag, false));
}
}
/* ══════════════════════════════════════════════════════════════════════
卡片
══════════════════════════════════════════════════════════════════════ */
function buildFileRow(file, asset) {
const link = element('a', 'dl-file');
link.href = asset.url;
link.dataset.press = '0.98';
const top = element('span', 'dl-file-top');
top.append(element('span', 'dl-file-role', file.role));
const size = formatSize(asset.size);
if (size) top.append(element('span', 'dl-file-size', size));
link.append(top, element('span', 'dl-file-name', asset.name), element('span', 'dl-file-note', file.note));
const row = element('li');
row.append(link);
return row;
}
function buildCard(card, release) {
const node = element('div', 'dl-card');
node.append(
element('p', 'dl-card-who', card.who),
element('p', 'dl-card-name', card.name),
element('p', 'dl-card-desc', card.desc),
);
const rows = card.files
.map((file) => ({ file, asset: release.assets.find((item) => file.match.test(item.name)) }))
.filter((row) => row.asset);
if (rows.length) {
const list = element('ul', 'dl-files');
for (const row of rows) list.append(buildFileRow(row.file, row.asset));
node.append(list);
} else {
node.append(element('p', 'dl-missing', `这一版没有发布「${card.name}」。`));
}
return node;
}
/** 卡片下方那段「这一版还有什么」,顺便说明清单是从哪儿来的 */
function renderNote(release) {
gridNote.textContent = '';
const say = (text) => gridNote.append(document.createTextNode(text));
const link = (text, href) => {
const anchor = element('a', null, text);
anchor.href = href;
anchor.target = '_blank';
anchor.rel = 'noreferrer';
gridNote.append(anchor);
};
if (source !== 'live') {
say(
'现在读不到版本列表 —— 多半是离线,或者 GitHub 那个匿名接口被限流了(每小时 60 次,按出口 IP 算)。',
);
say(`下面只列了当前版本 ${release.tag},文件名是按发布命名约定拼出来的直链;打不开就去 `);
link('Releases 页', RELEASES_PAGE);
say(' 自己挑一份。');
return;
}
const sums = release.assets.find((asset) => /^SHA256SUMS-.*\.txt$/i.test(asset.name));
say(
`这一版${release.publishedAt ? `(${release.publishedAt} 发布)` : ''}在发布页上有 ${release.assets.length} 个文件。`,
);
if (sums) {
say('全部文件的哈希在 ');
link(sums.name, sums.url);
say(' 里,装之前可以先核对;');
}
say('发布说明与更早的版本见 ');
link('Releases', release.notesUrl);
say('。这份清单每打开一次就重新读一次,站点里不留副本。');
}
/* ══════════════════════════════════════════════════════════════════════
选中某一版
══════════════════════════════════════════════════════════════════════ */
/** 版本选择器下面那行状态 —— 它要说清"现在选中的是哪一版" */
function renderStatus(release) {
if (source !== 'live') {
note.textContent =
'版本列表暂时取不到(离线,或 GitHub 的匿名接口被限流),下面是按发布命名约定拼出来的直链。';
return;
}
const marks = [];
if (release.latest) marks.push('最新版');
else if (release.prerelease) marks.push('预发布');
if (release.publishedAt) marks.push(`${release.publishedAt} 发布`);
marks.push(`${release.assets.length} 个文件`);
note.textContent = `已选 ${release.tag}(${marks.join(' · ')})· 列表读自本仓库的 GitHub Releases`;
}
function select(tag, animate = true) {
const release = releases.find((item) => item.tag === tag);
if (!release) return;
current = release;
for (const button of $$('.dl-tab', tabs)) {
button.setAttribute('aria-selected', String(button.dataset.tag === tag));
}
moveIndicator(tag, animate);
grid.textContent = '';
for (const card of CARDS) grid.append(buildCard(card, release));
renderStatus(release);
renderNote(release);
// 首屏那颗按钮:直接下这一版的服务端(其余三个包在下面自己挑)
const serverCard = CARDS.find((card) => card.key === 'server');
const server = release.assets.find((asset) => serverCard.files[0].match.test(asset.name));
if (server) {
primary.href = server.url;
primary.textContent = `下 ${release.tag} 服务端`;
} else {
primary.href = release.notesUrl;
primary.textContent = '去 Releases 挑一份';
}
// Linux 那一行:钉到当前选中的版本(老版本 tag 不是 x.y.z 形式时不带 --version)
const pinned = /^\d+\.\d+\.\d+$/.test(release.version) ? ` --version ${release.version}` : '';
command.textContent = `${command.dataset.base}${pinned}`;
// 卡片与文件行是刚建出来的,让 main.js 把按压反馈补挂上
document.dispatchEvent(new CustomEvent('ch:content'));
}
/* ══════════════════════════════════════════════════════════════════════
启动
══════════════════════════════════════════════════════════════════════ */
async function boot() {
// 命令原文先记下来:后面每次都从它拼(版本选择会改写这段文本)
command.dataset.base = command.textContent.trim();
try {
releases = markLatest(await fetchReleases());
if (!releases.length) throw new Error('发布列表是空的');
} catch (error) {
console.warn('[download] 取版本列表失败,改用按命名约定拼出来的直链:', error.message);
source = 'fallback';
releases = [fallbackRelease()];
}
renderTabs();
select(releases[0].tag, false);
window.addEventListener('resize', () => moveIndicator(current?.tag, false), { passive: true });
}
boot();
})();