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` 的落点之一)。
This commit is contained in:
LoveChengke committed 2026-10-06 19:35:59 +08:00
1 parent 0ea6bd887f
commit 17f55864bf
3 files changed
+617 -244

No files matched your search

+391 -163
View File
@@ -1,7 +1,7 @@
/**
* ClassHelper · 下载页(download.html)
*
* 这一页只做一件事:**挑一个版本,挑一份文件,点下去就下**。
* 这一页只做一件事:**挑一个版本、挑一个组件、挑一份文件,点下去就下**。
* 版本与文件清单有两种来路,按可靠性兜底:
*
* ① 当场读 GitHub Releases 接口 —— 拿到的就是发布页上真实存在的资源,
@@ -14,9 +14,13 @@
* 真离线时它本来也用不了 —— 所以真正要兜的只有"接口被限流",
* 那由 ② 兜住就够了。匿名接口每小时 60 次、按出口 IP 算,机房出口很容易撞到。
*
* 动效沿用 assets/motion.js 那套 beUI token(版本分段控件的指示器与首页实机截图那组同源)。
* 建完卡片会派发一次 `ch:content`,让 main.js 把按压反馈补挂上去
* —— 这些元素是拉到清单之后才出现的,main.js 启动时还看不到它们。
* 页面结构(照 ClassIsland 官网那页):
*
* 版本分段控件 → 一行状态 → 「装什么」分段控件 → 卡片(图标 / 名称 / 系统要求 / 主按钮 + 变体下拉)
*
* 默认停在**客户端**(进来最先要的多半是它),服务端与插件各是一个可切换的分组。
* 两个分段控件共用同一套指示器实现;建完卡片会派发一次 `ch:content`,
* 让 main.js 把按压反馈补挂上 —— 这些元素是拉到清单之后才出现的。
*/
(() => {
@@ -29,6 +33,9 @@
/** 版本选择器最多列几版;更早的去 Releases 页看 */
const MAX_VERSIONS = 12;
/** 进站默认的分组 —— 客户端 */
const DEFAULT_GROUP = 'client';
const motion = window.CHMotion ?? null;
const $ = (selector, root = document) => root.querySelector(selector);
@@ -37,98 +44,179 @@
/** 页面上那份兜底版本号,由 `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>';
/**
* 四个交付物 = 四张卡;卡里每一行 = Release 上的一个文件。
* 卡片顶上那个平台标记。单色描边、跟着文字色走 —— 彩色 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` 在没有接口时按命名约定拼直链的名字。
* `match` 用来在真实资源里认领这个文件,`expect` 是没有接口时按命名约定拼直链的名字。
* **两个都要跟着发布流程一起改** —— 名字口径与 README「开始使用」那张表、
* `.cache/release-notes-*.md` 里那份清单是同一套。
*/
const CARDS = [
const GROUPS = [
{
key: 'client',
who: '教室机器 / 学生电脑',
name: 'ClassHelper 班级端',
desc: '第一次打开时填服务器地址和班级码,之后开机就能看课表、作业、通知与成绩;断网时读本地缓存,恢复后自己刷新。',
files: [
label: '客户端',
note: {
text: '教室机器与学生电脑装这个:填一次服务器地址和班级码,之后开机就能用。先得有一台机器跑着服务端,它才连得上。',
more: { label: '先看服务端', group: 'server' },
},
cards: [
{
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`,
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',
who: '教师电脑 / 学校服务器 · Windows',
name: 'ClassHelper 服务端',
desc: '内含 Web 管理端与 Node 运行时,装上就算跑起来:建库、建号、开机自启都由它自己办。',
files: [
label: '服务端',
note: {
text: '整套系统只有一台(或一台云服务器)。装上就算跑起来:建库、建号、开机自启都由它自己办,Web 管理端也由它托管。',
},
cards: [
{
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`,
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`,
},
],
},
{
role: '校验值',
note: '安装脚本会自动取它核对',
match: /^classhelper-server-linux-x64-.*\.tar\.gz\.sha256$/i,
expect: (version) => `classhelper-server-linux-x64-${version}.tar.gz.sha256`,
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',
who: '教室大屏 · 装在 ClassIsland 上',
name: 'ClassIsland 联动插件',
desc: '把课表上报给服务端,把老师发的提醒弹到 ClassIsland 的全屏提醒上。上课时段的普通通知先收着,打下课铃补弹。',
files: [
label: '插件',
note: {
text: '给教室那台已经装了 ClassIsland 的机器加装 —— 课表从这里上报,老师发的提醒弹到全屏。',
},
cards: [
{
role: '插件包',
note: '在 ClassIsland 的插件页里选它安装',
match: /classislandplugin\.cipx$/i,
expect: () => 'ClassHelper.ClassIslandPlugin.cipx',
mark: 'plugin',
name: 'ClassIsland 联动插件',
req: '装在教室已有的 ClassIsland 2.x 上 · 教室大屏',
desc: '上课时段的普通通知先收着,打下课铃补弹;紧急通知与叫人立刻全屏提醒,可以语音朗读。',
files: [
{
role: '下载 .cipx',
note: '在 ClassIsland 的插件页里选它安装',
match: /classislandplugin\.cipx$/i,
expect: () => 'ClassHelper.ClassIslandPlugin.cipx',
},
],
},
],
},
];
const tabs = $('#ver-tabs');
const indicator = $('#ver-indicator');
const note = $('#ver-note');
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 (!tabs || !indicator || !note || !grid || !gridNote || !primary || !command) return;
if (
!versionHost ||
!versionIndicator ||
!versionStatus ||
!groupHost ||
!groupIndicator ||
!groupStatus ||
!grid ||
!gridNote ||
!primary ||
!command
) {
return;
}
/** 当前选中的版本清单来自哪儿:live(读到的) / fallback(拼出来的) */
let source = 'live';
let releases = [];
let current = null;
/** 当前状态:选中的版本 + 选中的分组 + 清单来自哪儿 */
const state = { releases: [], version: null, group: DEFAULT_GROUP, source: 'live' };
/* ══════════════════════════════════════════════════════════════════════
小工具
@@ -189,10 +277,12 @@
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 });
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 {
@@ -206,19 +296,46 @@
};
}
/** 把一张卡的 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:一个指示器在选项之间滑移
分段控件 —— beUI Tabs:一个指示器在选项之间滑移
版本与「装什么」共用这一套,所以这里不含任何"版本"的语义。
══════════════════════════════════════════════════════════════════════ */
let setIndicator = null;
if (motion) {
/** 位置用 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`;
});
setIndicator = (left, size, animate) => {
return (left, size, animate) => {
if (!animate || motion.reduceMotion()) {
x.jump(left);
width.jump(size);
@@ -229,97 +346,184 @@
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 selectedItem(host, value) {
return $$('.dl-seg-item', host).find((node) => node.dataset.value === value);
}
function renderTabs() {
$$('.dl-tab', tabs).forEach((node) => node.remove());
function markSelected(host, value) {
for (const item of $$('.dl-seg-item', host)) {
item.setAttribute('aria-selected', String(item.dataset.value === value));
}
}
for (const release of releases) {
const button = element('button', 'dl-tab');
/* ══════════════════════════════════════════════════════════════════════
版本分段控件(条目按发布列表建)
══════════════════════════════════════════════════════════════════════ */
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.tag = release.tag;
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-tab-flag', '最新'));
else if (release.prerelease) button.append(element('span', 'dl-tab-flag dl-tab-flag-quiet', '预发布'));
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', () => select(release.tag));
tabs.append(button);
button.addEventListener('click', () => selectVersion(release.tag));
versionHost.append(button);
}
markSelected(versionHost, state.version);
// 首帧之后重量一次:楷体是 CDN 来的,字体落定前量出来的宽度是错的
moveIndicator(current?.tag ?? releases[0]?.tag, false);
if (motion) {
motion.nextFrame(() => moveIndicator(current?.tag ?? releases[0]?.tag, false));
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 buildFileRow(file, asset) {
const link = element('a', 'dl-file');
function buildMenuItem({ file, asset }) {
const link = element('a');
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 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-file-size', size));
if (size) top.append(element('span', 'dl-menu-size', size));
link.append(top, element('span', 'dl-file-name', asset.name), element('span', 'dl-file-note', file.note));
link.append(top, element('span', 'dl-menu-file', asset.name));
if (file.note) link.append(element('span', 'dl-menu-note', file.note));
const row = element('li');
row.append(link);
return row;
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(
element('p', 'dl-card-who', card.who),
mark,
element('p', 'dl-card-name', card.name),
element('p', 'dl-card-req', card.req),
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}」。`));
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 renderNote(release) {
function renderGridNote(release) {
gridNote.textContent = '';
const say = (text) => gridNote.append(document.createTextNode(text));
const link = (text, href) => {
@@ -330,7 +534,7 @@
gridNote.append(anchor);
};
if (source !== 'live') {
if (state.source !== 'live') {
say(
'现在读不到版本列表 —— 多半是离线,或者 GitHub 那个匿名接口被限流了(每小时 60 次,按出口 IP 算)。',
);
@@ -355,45 +559,25 @@
}
/* ══════════════════════════════════════════════════════════════════════
选中某一版
渲染 + 选择
══════════════════════════════════════════════════════════════════════ */
/** 版本选择器下面那行状态 —— 它要说清"现在选中的是哪一版" */
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);
function render() {
const release = currentRelease();
const group = currentGroup();
grid.textContent = '';
for (const card of CARDS) grid.append(buildCard(card, release));
renderStatus(release);
renderNote(release);
for (const card of group.cards) grid.append(buildCard(card, 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} 服务端`;
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 挑一份';
@@ -403,10 +587,45 @@
const pinned = /^\d+\.\d+\.\d+$/.test(release.version) ? ` --version ${release.version}` : '';
command.textContent = `${command.dataset.base}${pinned}`;
// 卡片与文件行是刚建出来的,让 main.js 把按压反馈补挂上
// 卡片是刚建出来的,让 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;
});
/* ══════════════════════════════════════════════════════════════════════
启动
══════════════════════════════════════════════════════════════════════ */
@@ -416,18 +635,27 @@
command.dataset.base = command.textContent.trim();
try {
releases = markLatest(await fetchReleases());
if (!releases.length) throw new Error('发布列表是空的');
state.releases = markLatest(await fetchReleases());
if (!state.releases.length) throw new Error('发布列表是空的');
} catch (error) {
console.warn('[download] 取版本列表失败,改用按命名约定拼出来的直链:', error.message);
source = 'fallback';
releases = [fallbackRelease()];
state.source = 'fallback';
state.releases = [fallbackRelease()];
}
renderTabs();
select(releases[0].tag, false);
state.version = state.releases[0].tag;
renderVersionTabs();
bindGroupTabs();
render();
window.addEventListener('resize', () => moveIndicator(current?.tag, false), { passive: true });
window.addEventListener(
'resize',
() => {
moveVersionIndicator(false);
moveGroupIndicator(false);
},
{ passive: true },
);
}
boot();
+206 -71
View File
@@ -2088,20 +2088,28 @@ html:not(.reveal-pending) .display .reveal-grad {
/* ════════════════════════════════ 下载页 ═══════════════════════════════
download.html 专用。页面骨架与官网首页同一套(同样的色带、按钮、页脚),
这里只补两样只有这一页才有的东西:版本分段控件、每个交付物的文件卡。
这里只有三样这一页才有的东西:
两条口径和首页一致:
.dl-seg 分段控件 —— 版本选择与「装什么」选择共用一套(beUI Tabs 的指示器滑移)
.dl-card 交付物卡片 —— 图标 / 名称 / 系统要求 / 主按钮 + 变体下拉(照 ClassIsland 官网那排)
.dl-menu 变体下拉
两条口径与全站一致:
蓝(--blue-deep) 产品自己的强调色 —— 这里表示「这是一份真的能下的文件」
卡片一律纸面(--paper-raise)——「谁装什么」是要照着一项项核对的,跟名单一样 */
卡片一律纸面(--paper-raise)——「谁装什么、下哪个文件」是要一项项核对的,跟名单一样 */
/* ── 版本选择 ────────────────────────────────────────────────────────────
与首页实机截图那组分段控件同一套做法(beUI Tabs:一个指示器在选项之间滑移),
只是它管的是版本而不是图组,所以另起一套类名,不去动 .shots-*。 */
/* ── 分段控件 ────────────────────────────────────────────────────────────
一个指示器在选项之间滑移,位置由 SPRING_LAYOUT 驱动(见 assets/download.js)。
版本列表与「装什么」两处都用它 —— 所以这里是通用件,不含任何"版本"的语义。 */
.dl-pick {
margin: 34px 0 30px;
}
.dl-groups {
margin-top: 38px;
}
.dl-pick-label {
font-size: var(--fs-xs);
letter-spacing: 0.08em;
@@ -2109,7 +2117,11 @@ html:not(.reveal-pending) .display .reveal-grad {
margin-bottom: 10px;
}
.dl-tabs {
.band-paper .dl-pick-label {
color: var(--ink-3);
}
.dl-seg {
position: relative;
display: inline-flex;
gap: 4px;
@@ -2122,11 +2134,11 @@ html:not(.reveal-pending) .display .reveal-grad {
scrollbar-width: none;
}
.dl-tabs::-webkit-scrollbar {
.dl-seg::-webkit-scrollbar {
display: none;
}
.dl-tab {
.dl-seg-item {
position: relative;
z-index: 1;
display: inline-flex;
@@ -2145,13 +2157,13 @@ html:not(.reveal-pending) .display .reveal-grad {
transition: color 0.18s var(--ease-out);
}
.dl-tab:hover,
.dl-tab[aria-selected='true'] {
.dl-seg-item:hover,
.dl-seg-item[aria-selected='true'] {
color: var(--chalk);
}
/* 旗标:最新版 / 预发布。整排版本里只有它有,是这一行唯一的信息栅格 */
.dl-tab-flag {
/* 旗标:最新版 / 预发布。整排版本里只有它有,是那一行唯一的信息栅格 */
.dl-seg-flag {
font-size: 0.68rem;
line-height: 1;
padding: 3px 6px;
@@ -2161,13 +2173,13 @@ html:not(.reveal-pending) .display .reveal-grad {
border: 1px solid rgba(47, 143, 230, 0.4);
}
.dl-tab-flag-quiet {
.dl-seg-flag-quiet {
color: var(--chalk-2);
background: rgba(232, 238, 233, 0.07);
border-color: var(--board-line);
}
.dl-tabs-indicator {
.dl-seg-indicator {
position: absolute;
top: 4px;
left: 0;
@@ -2178,108 +2190,204 @@ html:not(.reveal-pending) .display .reveal-grad {
pointer-events: none;
}
/* 版本清单的来路 —— 这段话是这一页的"数据来源声明",取不到时要在这里说清楚 */
/* 分段控件下面那行说明 —— 这一页的"数据来源声明"与"现在选中的是什么"都写在这里 */
.dl-pick-note {
margin-top: 12px;
font-size: var(--fs-xs);
line-height: 1.85;
color: var(--chalk-3);
max-width: 48em;
max-width: 52em;
}
.dl-pick-note a {
color: var(--chalk-2);
}
.dl-pick-note code {
color: var(--chalk-2);
.band-paper .dl-pick-note {
color: var(--ink-3);
}
/* ── 文件卡(纸面) ───────────────────────────────────────────────────── */
.band-paper .dl-pick-note a {
color: var(--blue-deep);
}
/* 正好四张卡:宽度取 400px 起,桌面上一律两列(2×2 比 3+1 那种"落单"好读)。
`min(400px, 100%)` 那层是必要的:只写 400px 的话,窄屏(比 400 还窄)上轨道会硬撑到 400,
卡片就顶出视口右侧 —— 这是 auto-fit + minmax 的经典坑。 */
/* ── 交付物卡片(纸面) ─────────────────────────────────────────────────── */
/* 按组显示:一屏最多两张卡(服务端是 Windows + Linux)。
用 flex 而不是 grid 的 auto-fit:auto-fit 在只有一张卡时会把空轨道折叠掉、
让这张卡撑满整行;这里要的是"卡片有自己的宽度"。
整排**左对齐**(跟着这一节的标题走)—— 参考图那一页整体居中,但本站在每一带
都是左对齐的节奏(`.lead` 也只占 34em),孤零零一张卡居中会跟标题对不上。 */
.dl-grid {
margin-top: 36px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr));
margin-top: 26px;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 20px;
}
.dl-card {
flex: 1 1 360px;
min-width: 0;
max-width: 470px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
background: var(--paper-raise);
border: 1px solid var(--paper-rule);
border-radius: 6px;
padding: 22px 22px 18px;
padding: 30px 26px 26px;
}
/* 「谁在用」那行 —— 与首页特性块的 #标签同一个位置,用蓝色承担 */
.dl-card-who {
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--blue-deep);
/* 平台标记。单色描边,跟着卡片的文字色走 —— 换成彩色 logo 会在纸面上显得像贴纸 */
.dl-card-mark {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: var(--ink);
margin-bottom: 14px;
}
.dl-card-mark svg {
width: 44px;
height: 44px;
}
.dl-card-name {
margin-top: 5px;
font-size: 1.18rem;
font-size: 1.22rem;
font-weight: 700;
line-height: 1.45;
line-height: 1.4;
color: var(--ink);
}
/* 系统要求那行 —— 参考图里「Windows 10 及更高版本」站的就是这个位置 */
.dl-card-req {
margin-top: 5px;
font-size: var(--fs-xs);
line-height: 1.7;
color: var(--ink-3);
}
.dl-card-desc {
margin-top: 8px;
margin-top: 12px;
font-size: var(--fs-sm);
line-height: 1.85;
color: var(--ink-2);
max-width: 30em;
}
.dl-files {
margin-top: 16px;
padding: 0;
list-style: none;
/* ── 主按钮 + 变体下拉(一个整体的分裂按钮) ───────────────────────────── */
.dl-cta {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 一行 = 一个文件,整行可点(GitHub 的发布资源带 Content-Disposition,点了就下) */
.dl-file {
display: block;
padding: 10px 12px;
border: 1px solid var(--paper-rule);
margin-top: 20px;
border-radius: var(--radius);
background: var(--paper);
overflow: visible;
}
.dl-cta-main,
.dl-more > summary {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--fs-sm);
font-weight: 500;
line-height: 1;
padding: 14px 20px;
color: #fff;
background: var(--blue-deep);
text-decoration: none;
transition:
border-color 0.16s var(--ease-out),
background-color 0.16s var(--ease-out);
border: 1px solid var(--blue-deep);
cursor: pointer;
transition: background-color 0.16s var(--ease-out);
}
.dl-file:hover,
.dl-file:focus-visible {
border-color: var(--blue);
background: #eaf3fd;
.dl-cta-main {
border-radius: var(--radius) 0 0 var(--radius);
border-right-width: 0;
padding-inline: 26px;
}
.dl-file-top {
.dl-more > summary {
border-radius: 0 var(--radius) var(--radius) 0;
padding-inline: 12px;
border-left: 1px solid rgba(255, 255, 255, 0.28);
list-style: none;
}
.dl-more > summary::-webkit-details-marker {
display: none;
}
.dl-cta-main:hover,
.dl-more > summary:hover,
.dl-more[open] > summary {
background: #1280dd;
}
.dl-more > summary svg {
width: 16px;
height: 16px;
transition: transform 0.2s var(--ease-out);
}
.dl-more[open] > summary svg {
transform: rotate(180deg);
}
/* 没有变体时(插件那一张)不摆一个点不开的箭头 */
.dl-cta-single .dl-cta-main {
border-radius: var(--radius);
border-right-width: 1px;
}
.dl-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 2;
min-width: 260px;
max-width: min(360px, 88vw);
margin: 0;
padding: 6px;
list-style: none;
text-align: left;
background: var(--paper-raise);
border: 1px solid var(--paper-rule);
border-radius: 6px;
box-shadow: 0 18px 40px -22px rgba(23, 35, 31, 0.5);
}
.dl-menu a {
display: block;
padding: 9px 11px;
border-radius: var(--radius);
text-decoration: none;
transition: background-color 0.16s var(--ease-out);
}
.dl-menu a:hover,
.dl-menu a:focus-visible {
background: var(--paper);
}
.dl-menu-top {
display: flex;
align-items: baseline;
gap: 10px;
}
.dl-file-role {
.dl-menu-role {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--ink);
}
.dl-file-size {
.dl-menu-size {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--ink-3);
@@ -2287,27 +2395,38 @@ html:not(.reveal-pending) .display .reveal-grad {
white-space: nowrap;
}
.dl-file-name {
.dl-menu-file {
display: block;
margin-top: 4px;
margin-top: 3px;
font-family: var(--mono);
font-size: 0.72rem;
font-size: 0.68rem;
line-height: 1.6;
color: var(--ink-2);
overflow-wrap: anywhere;
}
.dl-file-note {
.dl-menu-note {
display: block;
margin-top: 3px;
font-size: var(--fs-xs);
color: var(--ink-3);
}
/* 主按钮下面那行:点下去到底拿到哪个文件(自托管场景里这个要看得见) */
.dl-card-file {
margin-top: 12px;
font-family: var(--mono);
font-size: 0.68rem;
line-height: 1.65;
color: var(--ink-3);
overflow-wrap: anywhere;
max-width: 100%;
}
/* 这一版没打这个包(老版本里真的会缺) */
.dl-missing {
margin-top: 16px;
padding: 12px;
margin-top: 18px;
padding: 12px 14px;
border: 1px dashed var(--paper-rule);
border-radius: var(--radius);
font-size: var(--fs-xs);
@@ -2315,7 +2434,7 @@ html:not(.reveal-pending) .display .reveal-grad {
}
.dl-note {
margin-top: 24px;
margin-top: 26px;
font-size: var(--fs-xs);
line-height: 1.9;
color: var(--ink-3);
@@ -2330,10 +2449,26 @@ html:not(.reveal-pending) .display .reveal-grad {
@media (max-width: 560px) {
.dl-card {
padding: 18px 16px 14px;
padding: 22px 18px 20px;
}
.dl-file-name {
font-size: 0.68rem;
.dl-cta {
width: 100%;
}
.dl-cta-main {
flex: 1 1 auto;
padding-inline: 16px;
}
.dl-menu {
right: auto;
left: 0;
min-width: 0;
width: 100%;
}
.dl-card-file {
font-size: 0.62rem;
}
}