Files
ClassHelper/website/assets/download.js
T
LoveChengke 17f55864bf feat(website): 下载页改成「客户端 / 服务端 / 插件」切换,卡片照 ClassIsland 官网那排重做
进来默认停在**客户端**(原先那颗主按钮指着服务端),服务端与插件各是一个可切换的分组。

- 新增「装什么」分段控件(客户端 / 服务端 / 插件):客户端一组一张卡,服务端一组两张
  (Windows + Linux),插件一组一张。它与版本选择器共用同一套指示器实现(beUI Tabs 的滑移;
  位置用 offsetLeft / offsetWidth 量,所以版本多了横滚之后也不会错位);
- 卡片改成 ClassIsland 官网那一页的样子:平台图标 + 名称 + 系统要求 + 主按钮 + 变体下拉。
  第一个文件是主按钮,其余进 `<details>` 下拉(点别处或 Esc 收起);插件那张只有一个文件,
  就不摆一个点不开的箭头;
- 主按钮下面留一行 mono 文件名与体积 —— 自托管场景里"点下去拿到哪个文件"是要看得见的;
- 首屏那颗主按钮跟着「版本 + 分组」走:默认是这一版客户端的安装版,切分组就跟着换;
- Linux 那个企鹅重画了一版:窄头宽肚 + 白脸 + 橙嘴橙脚(卡片底色恒为纸白,可以用实色)。
  原先那版头身糊成一团,44px 下认不出来;
- 卡片整排**左对齐**(跟着这一节的标题走):参考图那一页整体居中,但本站在每一带都是
  左对齐的节奏,孤零零一张卡居中会跟标题对不上。窄屏下卡片单列、分裂按钮撑满卡片宽。

`<meta name="ch-version">` 那一行的版本号没有跟着工作区里的 1.2.0 一起提交:仓库里另外
13 个落点还是 1.1.2,单独把它改成 1.2.0 会让 `pnpm version:check` 在 HEAD 上直接失败。
等整批版本号一起提交时它会自然跟上(它是 `scripts/version.mjs` 的落点之一)。
2026-10-06 19:35:59 +08:00

663 lines
28 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 算,机房出口很容易撞到。
*
* 页面结构(照 ClassIsland 官网那页):
*
* 版本分段控件 → 一行状态 → 「装什么」分段控件 → 卡片(图标 / 名称 / 系统要求 / 主按钮 + 变体下拉)
*
* 默认停在**客户端**(进来最先要的多半是它),服务端与插件各是一个可切换的分组。
* 两个分段控件共用同一套指示器实现;建完卡片会派发一次 `ch:content`,
* 让 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 DEFAULT_GROUP = 'client';
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();
const CHEVRON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
/**
* 卡片顶上那个平台标记。单色描边、跟着文字色走 —— 彩色 logo 落在纸面上像贴纸,
* 而且深浅两套主题下要各准备一份。图形只取"一眼能认出是哪个平台"的程度。
*/
const MARKS = {
windows:
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 5.5 10.4 4.5V11.3H3ZM11.6 4.4 21 3V11.3H11.6ZM3 12.7H10.4V19.5L3 18.5ZM11.6 12.7H21V21L11.6 19.6Z"/></svg>',
linux:
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
// 窄头 + 宽肚、脖子处收一下 —— 企鹅的识别点全在这个形状上
'<path fill="#16231e" d="M12 2.7C9.7 2.7 8 4.7 8 7.1c0 1.5.4 2.3 1 3.3-1.7 1.5-3.4 3.5-3.4 6 0 3 2.6 4.2 6.4 4.2s6.4-1.2 6.4-4.2c0-2.5-1.7-4.5-3.4-6 .6-1 1-1.8 1-3.3 0-2.4-1.7-4.4-4-4.4Z"/>' +
// 脸的两片白 + 瞳孔 + 嘴:Tux 就是靠这三样认出来的(卡片底色是恒定的纸白,可以用实色)
'<ellipse cx="10.35" cy="6.7" rx="1.35" ry="1.85" fill="#ffffff"/>' +
'<ellipse cx="13.65" cy="6.7" rx="1.35" ry="1.85" fill="#ffffff"/>' +
'<circle cx="10.5" cy="6.6" r=".62" fill="#16231e"/>' +
'<circle cx="13.5" cy="6.6" r=".62" fill="#16231e"/>' +
'<path fill="#f0a03c" d="M12 7.6 13.15 9.8h-2.3Z"/>' +
'<ellipse cx="9.4" cy="21.2" rx="2.3" ry=".95" fill="#f0a03c"/>' +
'<ellipse cx="14.6" cy="21.2" rx="2.3" ry=".95" fill="#f0a03c"/>' +
'</svg>',
plugin:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M9 3.4v4.4M15 3.4v4.4"/>' +
'<path d="M6.6 7.8H17.4V11.2A5.4 5.4 0 0 1 6.6 11.2Z"/>' +
'<path d="M12 16.6v4"/></svg>',
};
/**
* 分组 = 「装什么」。一个分组下可以有几张卡(服务端就是 Windows + Linux 两张),
* 每张卡是一个交付物,卡里的第一个文件是主按钮,其余进变体下拉。
*
* `match` 用来在真实资源里认领这个文件,`expect` 是没有接口时按命名约定拼直链的名字。
* **两个都要跟着发布流程一起改** —— 名字口径与 README「开始使用」那张表、
* `.cache/release-notes-*.md` 里那份清单是同一套。
*/
const GROUPS = [
{
key: 'client',
label: '客户端',
note: {
text: '教室机器与学生电脑装这个:填一次服务器地址和班级码,之后开机就能用。先得有一台机器跑着服务端,它才连得上。',
more: { label: '先看服务端', group: 'server' },
},
cards: [
{
mark: 'windows',
name: 'ClassHelper 班级端',
req: 'Windows 10 及更高版本 · 教室机器 / 学生电脑',
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',
label: '服务端',
note: {
text: '整套系统只有一台(或一台云服务器)。装上就算跑起来:建库、建号、开机自启都由它自己办,Web 管理端也由它托管。',
},
cards: [
{
mark: 'windows',
name: 'ClassHelper 服务端',
req: 'Windows 10 及更高版本 · 教师电脑 / 学校服务器',
desc: '内含 Web 管理端与 Node 运行时。装完浏览器打开 http://<服务器地址>:4000 就能登录。',
files: [
{
role: '下载安装程序',
note: '双击安装,自动建库建号、开机自启',
match: /-server-setup\.exe$/i,
expect: (version) => `ClassHelper-${version}-x64-server-setup.exe`,
},
{
role: '下载 SHA256 清单',
note: '这一版全部文件的哈希,装之前可以核对',
match: /^SHA256SUMS-.*\.txt$/i,
expect: (version) => `SHA256SUMS-${version}.txt`,
},
],
},
{
mark: 'linux',
name: 'ClassHelper 服务端',
req: 'Linux x64 · systemd(Debian 10 / Ubuntu 20.04 及更高)',
desc: '一行命令装成 systemd 服务,带 classhelper 运维命令;也可以下压缩包自己解。',
files: [
{
role: '下载 tar.gz',
note: '解压即用,含内置 Node 运行时',
match: /^classhelper-server-linux-x64-.*\.tar\.gz$/i,
expect: (version) => `classhelper-server-linux-x64-${version}.tar.gz`,
},
{
role: '下载 .sha256',
note: '安装脚本会自动取它核对压缩包',
match: /^classhelper-server-linux-x64-.*\.tar\.gz\.sha256$/i,
expect: (version) => `classhelper-server-linux-x64-${version}.tar.gz.sha256`,
},
],
},
],
},
{
key: 'plugin',
label: '插件',
note: {
text: '给教室那台已经装了 ClassIsland 的机器加装 —— 课表从这里上报,老师发的提醒弹到全屏。',
},
cards: [
{
mark: 'plugin',
name: 'ClassIsland 联动插件',
req: '装在教室已有的 ClassIsland 2.x 上 · 教室大屏',
desc: '上课时段的普通通知先收着,打下课铃补弹;紧急通知与叫人立刻全屏提醒,可以语音朗读。',
files: [
{
role: '下载 .cipx',
note: '在 ClassIsland 的插件页里选它安装',
match: /classislandplugin\.cipx$/i,
expect: () => 'ClassHelper.ClassIslandPlugin.cipx',
},
],
},
],
},
];
const versionHost = $('#ver-tabs');
const versionIndicator = $('#ver-indicator');
const versionStatus = $('#ver-note');
const groupHost = $('#group-tabs');
const groupIndicator = $('#group-indicator');
const groupStatus = $('#group-note');
const grid = $('#dl-grid');
const gridNote = $('#dl-note');
const primary = $('#ver-primary');
const command = $('#cmd-linux code');
if (
!versionHost ||
!versionIndicator ||
!versionStatus ||
!groupHost ||
!groupIndicator ||
!groupStatus ||
!grid ||
!gridNote ||
!primary ||
!command
) {
return;
}
/** 当前状态:选中的版本 + 选中的分组 + 清单来自哪儿 */
const state = { releases: [], version: null, group: DEFAULT_GROUP, source: 'live' };
/* ══════════════════════════════════════════════════════════════════════
小工具
══════════════════════════════════════════════════════════════════════ */
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 group of GROUPS) {
for (const card of group.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,
};
}
/** 把一张卡的 files 落成"这一版真实存在的资源";认领不到的行直接丢掉 */
function resolveFiles(card, release) {
return card.files
.map((file) => ({ file, asset: release.assets.find((item) => file.match.test(item.name)) }))
.filter((row) => row.asset);
}
function currentRelease() {
return state.releases.find((item) => item.tag === state.version) ?? state.releases[0];
}
function currentGroup() {
return GROUPS.find((item) => item.key === state.group) ?? GROUPS[0];
}
/* ══════════════════════════════════════════════════════════════════════
分段控件 —— beUI Tabs:一个指示器在选项之间滑移
版本与「装什么」共用这一套,所以这里不含任何"版本"的语义。
══════════════════════════════════════════════════════════════════════ */
/** 位置用 offsetLeft / offsetWidth 量:指示器绝对定位在分段控件(position: relative)里,
两者参照的都是容器的内边距边 —— 而且这样**与容器的横向滚动无关**
(版本多了以后这一排可以横滚,用 getBoundingClientRect 会在滚动后对不上)。 */
function createIndicator(indicator) {
if (!motion) {
// 动效层没加载出来也要能用:直接落位
return (left, width) => {
indicator.style.transform = `translateX(${left}px)`;
indicator.style.width = `${width}px`;
};
}
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`;
});
return (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);
};
}
function selectedItem(host, value) {
return $$('.dl-seg-item', host).find((node) => node.dataset.value === value);
}
function markSelected(host, value) {
for (const item of $$('.dl-seg-item', host)) {
item.setAttribute('aria-selected', String(item.dataset.value === value));
}
}
/* ══════════════════════════════════════════════════════════════════════
版本分段控件(条目按发布列表建)
══════════════════════════════════════════════════════════════════════ */
const setVersionIndicator = createIndicator(versionIndicator);
function moveVersionIndicator(animate) {
const item = selectedItem(versionHost, state.version);
if (item) setVersionIndicator(item.offsetLeft, item.offsetWidth, animate);
}
function renderVersionTabs() {
$$('.dl-seg-item', versionHost).forEach((node) => node.remove());
for (const release of state.releases) {
const button = element('button', 'dl-seg-item');
button.type = 'button';
button.setAttribute('role', 'tab');
button.dataset.value = 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-seg-flag', '最新'));
else if (release.prerelease) {
button.append(element('span', 'dl-seg-flag dl-seg-flag-quiet', '预发布'));
}
button.addEventListener('click', () => selectVersion(release.tag));
versionHost.append(button);
}
markSelected(versionHost, state.version);
// 首帧之后重量一次:楷体是 CDN 来的,字体落定前量出来的宽度是错的
moveVersionIndicator(false);
if (motion) motion.nextFrame(() => moveVersionIndicator(false));
}
/* ══════════════════════════════════════════════════════════════════════
「装什么」分段控件(条目写在 HTML 里)
══════════════════════════════════════════════════════════════════════ */
const setGroupIndicator = createIndicator(groupIndicator);
function moveGroupIndicator(animate) {
const item = selectedItem(groupHost, state.group);
if (item) setGroupIndicator(item.offsetLeft, item.offsetWidth, animate);
}
function bindGroupTabs() {
for (const item of $$('.dl-seg-item', groupHost)) {
item.dataset.press = '0.96';
item.addEventListener('click', () => selectGroup(item.dataset.value));
}
markSelected(groupHost, state.group);
moveGroupIndicator(false);
if (motion) motion.nextFrame(() => moveGroupIndicator(false));
}
/* ══════════════════════════════════════════════════════════════════════
卡片
══════════════════════════════════════════════════════════════════════ */
function buildMenuItem({ file, asset }) {
const link = element('a');
link.href = asset.url;
const top = element('span', 'dl-menu-top');
top.append(element('span', 'dl-menu-role', file.role));
const size = formatSize(asset.size);
if (size) top.append(element('span', 'dl-menu-size', size));
link.append(top, element('span', 'dl-menu-file', asset.name));
if (file.note) link.append(element('span', 'dl-menu-note', file.note));
const item = element('li');
item.append(link);
return item;
}
/** 主按钮 + 变体下拉:一个整体,右边那条箭头只负责展开剩下的文件 */
function buildCta(rows) {
const [first, ...rest] = rows;
const wrap = element('div', `dl-cta${rest.length ? '' : ' dl-cta-single'}`);
const main = element('a', 'dl-cta-main', first.file.role);
main.href = first.asset.url;
main.dataset.press = '0.97';
wrap.append(main);
if (rest.length) {
const more = element('details', 'dl-more');
const summary = element('summary');
summary.setAttribute('aria-label', `还有 ${rest.length} 个文件`);
summary.innerHTML = CHEVRON; // 静态常量,不含外部文本
const menu = element('ul', 'dl-menu');
for (const row of rest) menu.append(buildMenuItem(row));
more.append(summary, menu);
wrap.append(more);
}
return wrap;
}
function buildCard(card, release) {
const node = element('div', 'dl-card');
const mark = element('span', 'dl-card-mark');
mark.innerHTML = MARKS[card.mark] ?? ''; // 静态常量,不含外部文本
node.append(
mark,
element('p', 'dl-card-name', card.name),
element('p', 'dl-card-req', card.req),
element('p', 'dl-card-desc', card.desc),
);
const rows = resolveFiles(card, release);
if (!rows.length) {
node.append(element('p', 'dl-missing', '这一版没有发布这个包。'));
return node;
}
node.append(buildCta(rows));
const size = formatSize(rows[0].asset.size);
node.append(element('p', 'dl-card-file', `${rows[0].asset.name}${size ? ` · ${size}` : ''}`));
return node;
}
/* ══════════════════════════════════════════════════════════════════════
两行状态文字
══════════════════════════════════════════════════════════════════════ */
/** 版本选择器下面那行:现在选中的是哪一版 */
function renderVersionStatus(release) {
if (state.source !== 'live') {
versionStatus.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} 个文件`);
versionStatus.textContent = `已选 ${release.tag}(${marks.join(' · ')})· 列表读自本仓库的 GitHub Releases`;
}
/** 「装什么」下面那行:这一组装在哪儿、装之前要不要先有别的东西 */
function renderGroupStatus(group) {
groupStatus.textContent = '';
groupStatus.append(document.createTextNode(group.note.text));
if (!group.note.more) return;
groupStatus.append(document.createTextNode(' '));
const anchor = element('a', null, `${group.note.more.label} →`);
anchor.href = `#${group.note.more.group}`;
anchor.addEventListener('click', (event) => {
event.preventDefault();
selectGroup(group.note.more.group);
groupHost.scrollIntoView({ block: 'center' });
});
groupStatus.append(anchor);
}
/** 卡片下方那段「这一版还有什么」,顺便说明清单是从哪儿来的 */
function renderGridNote(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 (state.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 render() {
const release = currentRelease();
const group = currentGroup();
grid.textContent = '';
for (const card of group.cards) grid.append(buildCard(card, release));
renderVersionStatus(release);
renderGroupStatus(group);
renderGridNote(release);
// 首屏那颗按钮跟着「版本 + 分组」走:默认就是这一版客户端的安装版
const first = resolveFiles(group.cards[0], release)[0];
if (first) {
primary.href = first.asset.url;
primary.textContent = `下载${group.label} ${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'));
}
function selectVersion(tag, animate = true) {
if (!state.releases.some((item) => item.tag === tag)) return;
state.version = tag;
markSelected(versionHost, tag);
moveVersionIndicator(animate);
render();
}
function selectGroup(key, animate = true) {
if (!GROUPS.some((item) => item.key === key)) return;
state.group = key;
markSelected(groupHost, key);
moveGroupIndicator(animate);
render();
}
/* ══════════════════════════════════════════════════════════════════════
变体下拉:点别处或按 Esc 收起来(<details> 自己管键盘与开合)
══════════════════════════════════════════════════════════════════════ */
document.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target : null;
const inside = target?.closest('.dl-more') ?? null;
// 点了菜单里的链接也算"用完",一并收起
const clickedItem = Boolean(target?.closest('.dl-menu a'));
for (const node of $$('.dl-more[open]')) {
if (node !== inside || clickedItem) node.open = false;
}
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
for (const node of $$('.dl-more[open]')) node.open = false;
});
/* ══════════════════════════════════════════════════════════════════════
启动
══════════════════════════════════════════════════════════════════════ */
async function boot() {
// 命令原文先记下来:后面每次都从它拼(版本选择会改写这段文本)
command.dataset.base = command.textContent.trim();
try {
state.releases = markLatest(await fetchReleases());
if (!state.releases.length) throw new Error('发布列表是空的');
} catch (error) {
console.warn('[download] 取版本列表失败,改用按命名约定拼出来的直链:', error.message);
state.source = 'fallback';
state.releases = [fallbackRelease()];
}
state.version = state.releases[0].tag;
renderVersionTabs();
bindGroupTabs();
render();
window.addEventListener(
'resize',
() => {
moveVersionIndicator(false);
moveGroupIndicator(false);
},
{ passive: true },
);
}
boot();
})();