From 17f55864bf61972e66eb2077ba0255fd4c51e986 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Tue, 6 Oct 2026 19:35:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(website):=20=E4=B8=8B=E8=BD=BD=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E6=88=90=E3=80=8C=E5=AE=A2=E6=88=B7=E7=AB=AF=20/=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=20/=20=E6=8F=92=E4=BB=B6=E3=80=8D?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=8C=E5=8D=A1=E7=89=87=E7=85=A7=20ClassI?= =?UTF-8?q?sland=20=E5=AE=98=E7=BD=91=E9=82=A3=E6=8E=92=E9=87=8D=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进来默认停在**客户端**(原先那颗主按钮指着服务端),服务端与插件各是一个可切换的分组。 - 新增「装什么」分段控件(客户端 / 服务端 / 插件):客户端一组一张卡,服务端一组两张 (Windows + Linux),插件一组一张。它与版本选择器共用同一套指示器实现(beUI Tabs 的滑移; 位置用 offsetLeft / offsetWidth 量,所以版本多了横滚之后也不会错位); - 卡片改成 ClassIsland 官网那一页的样子:平台图标 + 名称 + 系统要求 + 主按钮 + 变体下拉。 第一个文件是主按钮,其余进 `
` 下拉(点别处或 Esc 收起);插件那张只有一个文件, 就不摆一个点不开的箭头; - 主按钮下面留一行 mono 文件名与体积 —— 自托管场景里"点下去拿到哪个文件"是要看得见的; - 首屏那颗主按钮跟着「版本 + 分组」走:默认是这一版客户端的安装版,切分组就跟着换; - Linux 那个企鹅重画了一版:窄头宽肚 + 白脸 + 橙嘴橙脚(卡片底色恒为纸白,可以用实色)。 原先那版头身糊成一团,44px 下认不出来; - 卡片整排**左对齐**(跟着这一节的标题走):参考图那一页整体居中,但本站在每一带都是 左对齐的节奏,孤零零一张卡居中会跟标题对不上。窄屏下卡片单列、分裂按钮撑满卡片宽。 `` 那一行的版本号没有跟着工作区里的 1.2.0 一起提交:仓库里另外 13 个落点还是 1.1.2,单独把它改成 1.2.0 会让 `pnpm version:check` 在 HEAD 上直接失败。 等整批版本号一起提交时它会自然跟上(它是 `scripts/version.mjs` 的落点之一)。 --- website/assets/download.js | 554 ++++++++++++++++++++++++++----------- website/assets/styles.css | 277 ++++++++++++++----- website/download.html | 30 +- 3 files changed, 617 insertions(+), 244 deletions(-) diff --git a/website/assets/download.js b/website/assets/download.js index 7f2da3e..8b01a75 100644 --- a/website/assets/download.js +++ b/website/assets/download.js @@ -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 = + ''; + /** - * 四个交付物 = 四张卡;卡里每一行 = Release 上的一个文件。 + * 卡片顶上那个平台标记。单色描边、跟着文字色走 —— 彩色 logo 落在纸面上像贴纸, + * 而且深浅两套主题下要各准备一份。图形只取"一眼能认出是哪个平台"的程度。 + */ + const MARKS = { + windows: + '', + linux: + '', + plugin: + '', + }; + + /** + * 分组 = 「装什么」。一个分组下可以有几张卡(服务端就是 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 收起来(
自己管键盘与开合) + ══════════════════════════════════════════════════════════════════════ */ + + 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(); diff --git a/website/assets/styles.css b/website/assets/styles.css index a160993..8c10d83 100644 --- a/website/assets/styles.css +++ b/website/assets/styles.css @@ -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; } } diff --git a/website/download.html b/website/download.html index a166e6c..b3bc416 100644 --- a/website/download.html +++ b/website/download.html @@ -89,9 +89,9 @@