修复下载页面的稳定版、预发布版和 Nightly 通道 (#326)

下载页面目前只有稳定版和预发布版两个通道,并将 GitHub 返回的第一个预发布版本作为普通预发布版。官方新增 Nightly 后,这会误选
`nightly`,并拼出错误的版本标签和下载地址。

此次修改为中英文下载页面补齐稳定版、预发布版、开发版(Nightly)三个通道。普通预发布版明确排除 Nightly
和草稿;下载链接按发布资产中的真实文件名和地址匹配,支持 Nightly 标签与安装包版本号不同的情况。

各通道分别处理接口失败,稳定版和预发布版保留版本回退,Nightly 无法加载或暂无资产时提供官方发布页入口。同步将预发布版回退版本更新为
`2.7.0`。

验证:

- `pnpm docs:build` 构建通过。
- `pnpm exec eslint useLatestVersion.ts releaseUtils.ts metadata.data.ts
tests/releaseUtils.test.ts` 检查通过。
- `node --experimental-strip-types --test tests/releaseUtils.test.ts`:6
项回归测试通过,覆盖通道区分、Nightly 下载地址、GUI 资产版本差异、Android 安装包、缺失资产和接口失败回退。
- 浏览器验证中英文通道切换、操作系统和架构筛选、GitHub 加速前缀及构建后的页面预览。

Co-authored-by: Codex <codex@local>
This commit is contained in:
刚刚
2026-10-17 12:21:24 +08:00
committed by GitHub
co-authored by Codex
parent 88760c0e8f
commit a3858db878
6 changed files with 173 additions and 57 deletions
+33 -14
View File
@@ -5,15 +5,16 @@ home: hello
<script setup lang="ts">
import { computed, ref } from 'vue'
import { data } from '../../metadata.data.js'
import { resolveDownloadUrl } from '../../releaseUtils.ts'
import { useLatestVersion } from '../../useLatestVersion.ts'
const { latestVersion, prereleaseVersion, loading, error } = useLatestVersion()
const { latestVersion, prereleaseVersion, latestRelease, prereleaseRelease, nightlyRelease, loading } = useLatestVersion()
interface Package {
os: string
arch: string
gui_pkg_tmpl: record<string, string>
cli_pkg_tmpl: record<string, string> // key: format, value: url
gui_pkg_tmpl: Record<string, string>
cli_pkg_tmpl: Record<string, string> // key: format, value: url
comment?: string
}
@@ -157,20 +158,30 @@ const all_proxy = new Set(data.github_accels)
const channel = ref('easytier_latest_version')
const url = 'https://github.com/EasyTier/EasyTier/releases/tag/v'
const url = 'https://github.com/EasyTier/EasyTier/releases/tag/'
const filter_os = ref('')
const filter_arch = ref('')
const accel_proxy = ref('')
const vers = computed(() => ({
easytier_latest_version: (!loading.value && !error.value && latestVersion.value) ? latestVersion.value : data.easytier_latest_version,
easytier_pre_release_version: (!loading.value && !error.value && prereleaseVersion.value) ? prereleaseVersion.value : data.easytier_pre_release_version,
easytier_latest_version: latestVersion.value ?? data.easytier_latest_version,
easytier_pre_release_version: prereleaseVersion.value ?? data.easytier_pre_release_version,
}))
const currentVersion = computed(() => vers.value[channel.value as keyof typeof vers.value])
const currentRelease = computed(() => {
if (channel.value === 'nightly')
return nightlyRelease.value
return channel.value === 'easytier_latest_version' ? latestRelease.value : prereleaseRelease.value
})
const currentTag = computed(() => channel.value === 'nightly' ? 'nightly' : currentRelease.value?.tag_name ?? `v${currentVersion.value}`)
const currentTitle = computed(() => channel.value === 'nightly' ? 'Nightly' : currentTag.value)
function renderUrlTmpl(url_tmpl: string): string {
return accel_proxy.value + url_tmpl.replace(/\{\}/g, currentVersion.value)
function renderLinks(templates: Record<string, string>) {
return Object.entries(templates).flatMap(([format, template]) => {
const downloadUrl = resolveDownloadUrl(template, currentRelease.value, channel.value === 'nightly' ? undefined : currentVersion.value)
return downloadUrl ? [{ format, url: accel_proxy.value + downloadUrl }] : []
})
}
</script>
@@ -192,10 +203,18 @@ The command line program package includes four executables:
<select name="channel-select" id="channel-select" v-model="channel" class="filter-select">
<option value="easytier_latest_version"> Stable Version(v{{ vers.easytier_latest_version }}) </option>
<option value="easytier_pre_release_version"> Pre-release Version(v{{ vers.easytier_pre_release_version }}) </option>
<option value="nightly"> Development (Nightly) </option>
</select>
</div>
## <a :href="url + currentVersion">EasyTier v{{ currentVersion }}</a> { #latest }
<p v-if="channel === 'nightly'">Nightly follows the main branch and is intended for testing the latest features. It may be unstable.</p>
## <a :href="url + currentTag">EasyTier {{ currentTitle }}</a> { #latest }
<p v-if="channel === 'nightly' && !nightlyRelease?.assets.length">
<span v-if="loading">Loading Nightly downloads…</span>
<span v-else>Nightly downloads could not be loaded. Please visit the <a :href="url + 'nightly'">Nightly release page</a>.</span>
</p>
- GitHub Acceleration
<div>
@@ -221,7 +240,7 @@ The command line program package includes four executables:
</select>
</div>
<table>
<table v-if="channel !== 'nightly' || nightlyRelease?.assets.length">
<thead>
<tr>
@@ -239,14 +258,14 @@ The command line program package includes four executables:
<td> {{ pkg.arch }} </td>
<td>
<a v-for="(url_tmpl, format) in pkg.gui_pkg_tmpl" class="download-link-span" :href="renderUrlTmpl(url_tmpl)">
{{format}}
<a v-for="link in renderLinks(pkg.gui_pkg_tmpl)" :key="link.format" class="download-link-span" :href="link.url">
{{link.format}}
</a>
</td>
<td>
<a v-for="(url_tmpl, format) in pkg.cli_pkg_tmpl" class="download-link-span" :href="renderUrlTmpl(url_tmpl)">
{{format}}
<a v-for="link in renderLinks(pkg.cli_pkg_tmpl)" :key="link.format" class="download-link-span" :href="link.url">
{{link.format}}
</a>
</td>
+33 -14
View File
@@ -5,15 +5,16 @@ home: hello
<script setup lang="ts">
import { computed, ref } from 'vue'
import { data } from '../metadata.data.js'
import { resolveDownloadUrl } from '../releaseUtils.ts'
import { useLatestVersion } from '../useLatestVersion.ts'
const { latestVersion, prereleaseVersion, loading, error } = useLatestVersion()
const { latestVersion, prereleaseVersion, latestRelease, prereleaseRelease, nightlyRelease, loading } = useLatestVersion()
interface Package {
os: string
arch: string
gui_pkg_tmpl: record<string, string>
cli_pkg_tmpl: record<string, string> // key: format, value: url
gui_pkg_tmpl: Record<string, string>
cli_pkg_tmpl: Record<string, string> // key: format, value: url
comment?: string
}
@@ -157,20 +158,30 @@ const all_proxy = new Set(data.github_accels)
const channel = ref('easytier_latest_version')
const url = 'https://github.com/EasyTier/EasyTier/releases/tag/v'
const url = 'https://github.com/EasyTier/EasyTier/releases/tag/'
const filter_os = ref('')
const filter_arch = ref('')
const accel_proxy = ref('')
const vers = computed(() => ({
easytier_latest_version: (!loading.value && !error.value && latestVersion.value) ? latestVersion.value : data.easytier_latest_version,
easytier_pre_release_version: (!loading.value && !error.value && prereleaseVersion.value) ? prereleaseVersion.value : data.easytier_pre_release_version,
easytier_latest_version: latestVersion.value ?? data.easytier_latest_version,
easytier_pre_release_version: prereleaseVersion.value ?? data.easytier_pre_release_version,
}))
const currentVersion = computed(() => vers.value[channel.value as keyof typeof vers.value])
const currentRelease = computed(() => {
if (channel.value === 'nightly')
return nightlyRelease.value
return channel.value === 'easytier_latest_version' ? latestRelease.value : prereleaseRelease.value
})
const currentTag = computed(() => channel.value === 'nightly' ? 'nightly' : currentRelease.value?.tag_name ?? `v${currentVersion.value}`)
const currentTitle = computed(() => channel.value === 'nightly' ? 'Nightly' : currentTag.value)
function renderUrlTmpl(url_tmpl: string): string {
return accel_proxy.value + url_tmpl.replace(/\{\}/g, currentVersion.value)
function renderLinks(templates: Record<string, string>) {
return Object.entries(templates).flatMap(([format, template]) => {
const downloadUrl = resolveDownloadUrl(template, currentRelease.value, channel.value === 'nightly' ? undefined : currentVersion.value)
return downloadUrl ? [{ format, url: accel_proxy.value + downloadUrl }] : []
})
}
</script>
@@ -192,10 +203,18 @@ function renderUrlTmpl(url_tmpl: string): string {
<select name="channel-select" id="channel-select" v-model="channel" class="filter-select">
<option value="easytier_latest_version"> 稳定版(v{{ vers.easytier_latest_version }}) </option>
<option value="easytier_pre_release_version"> 预发布版(v{{ vers.easytier_pre_release_version }}) </option>
<option value="nightly"> 开发版(Nightly) </option>
</select>
</div>
## <a :href="url + currentVersion">EasyTier v{{ currentVersion }}</a> { #latest }
<p v-if="channel === 'nightly'">Nightly 跟随 main 分支更新,适合测试最新功能,可能不稳定。</p>
## <a :href="url + currentTag">EasyTier {{ currentTitle }}</a> { #latest }
<p v-if="channel === 'nightly' && !nightlyRelease?.assets.length">
<span v-if="loading">正在加载 Nightly 下载文件…</span>
<span v-else>暂时无法获取 Nightly 下载文件,请前往 <a :href="url + 'nightly'">Nightly 发布页</a>。</span>
</p>
- Github 加速
<div>
@@ -221,7 +240,7 @@ function renderUrlTmpl(url_tmpl: string): string {
</select>
</div>
<table>
<table v-if="channel !== 'nightly' || nightlyRelease?.assets.length">
<thead>
<tr>
@@ -239,14 +258,14 @@ function renderUrlTmpl(url_tmpl: string): string {
<td> {{ pkg.arch }} </td>
<td>
<a v-for="(url_tmpl, format) in pkg.gui_pkg_tmpl" class="download-link-span" :href="renderUrlTmpl(url_tmpl)">
{{format}}
<a v-for="link in renderLinks(pkg.gui_pkg_tmpl)" :key="link.format" class="download-link-span" :href="link.url">
{{link.format}}
</a>
</td>
<td>
<a v-for="(url_tmpl, format) in pkg.cli_pkg_tmpl" class="download-link-span" :href="renderUrlTmpl(url_tmpl)">
{{format}}
<a v-for="link in renderLinks(pkg.cli_pkg_tmpl)" :key="link.format" class="download-link-span" :href="link.url">
{{link.format}}
</a>
</td>
+1 -1
View File
@@ -2,7 +2,7 @@ export default {
load() {
return {
easytier_latest_version: '2.6.4',
easytier_pre_release_version: '2.6.3',
easytier_pre_release_version: '2.7.0',
github_accels: [
'https://ghfast.top/',
'https://v6.gh-proxy.org/',
+22
View File
@@ -0,0 +1,22 @@
export interface Release {
tag_name: string
prerelease?: boolean
draft?: boolean
assets: Array<{ name: string, browser_download_url: string }>
}
export function findPrerelease(releases: Release[]): Release | null {
return releases.find(release => release.prerelease && !release.draft && release.tag_name !== 'nightly') ?? null
}
export function resolveDownloadUrl(template: string, release: Release | null, fallbackVersion?: string): string | undefined {
if (release) {
const filename = template.slice(template.lastIndexOf('/') + 1)
const pattern = filename.split('{}')
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[^/]+')
return release.assets.find(asset => new RegExp(`^${pattern}$`).test(asset.name))?.browser_download_url
}
return fallbackVersion ? template.replace(/\{\}/g, fallbackVersion) : undefined
}
+54
View File
@@ -0,0 +1,54 @@
import type { Release } from '../releaseUtils.ts'
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the built-in runner without adding a test framework.
import { test } from 'node:test'
import { findPrerelease, resolveDownloadUrl } from '../releaseUtils.ts'
const cliTemplate = 'https://github.com/EasyTier/EasyTier/releases/download/v{}/easytier-linux-x86_64-v{}.zip'
const guiTemplate = 'https://github.com/EasyTier/EasyTier/releases/download/v{}/easytier-gui_{}_x64-setup.exe'
function release(tag_name: string, assets: string[] = [], prerelease = true): Release {
return {
tag_name,
prerelease,
assets: assets.map(name => ({ name, browser_download_url: `https://github.com/EasyTier/EasyTier/releases/download/${tag_name}/${name}` })),
}
}
test('ordinary prereleases exclude rolling nightly and draft releases', () => {
const prerelease = release('v2.7.0')
assert.equal(findPrerelease([
release('nightly'),
{ ...release('v2.8.0'), draft: true },
prerelease,
release('v2.6.4', [], false),
]), prerelease)
assert.equal(findPrerelease([release('nightly'), release('v2.6.4', [], false)]), null)
})
test('nightly uses the actual release tag and versioned asset name', () => {
const nightly = release('nightly', ['easytier-linux-x86_64-v2.7.0.zip'])
assert.equal(resolveDownloadUrl(cliTemplate, nightly), 'https://github.com/EasyTier/EasyTier/releases/download/nightly/easytier-linux-x86_64-v2.7.0.zip')
})
test('GUI asset versions can differ from the release version', () => {
const prerelease = release('v2.6.1', ['easytier-gui_2.6.0_x64-setup.exe'])
assert.equal(resolveDownloadUrl(guiTemplate, prerelease), 'https://github.com/EasyTier/EasyTier/releases/download/v2.6.1/easytier-gui_2.6.0_x64-setup.exe')
})
test('Android assets without a version placeholder also resolve', () => {
const nightly = release('nightly', ['app-arm64-release.apk'])
assert.equal(resolveDownloadUrl('https://github.com/EasyTier/EasyTier/releases/download/v{}/app-arm64-release.apk', nightly), 'https://github.com/EasyTier/EasyTier/releases/download/nightly/app-arm64-release.apk')
})
test('missing assets do not produce invented download links', () => {
assert.equal(resolveDownloadUrl(cliTemplate, release('nightly', ['easytier-linux-aarch64-v2.7.0.zip'])), undefined)
assert.equal(resolveDownloadUrl(guiTemplate, release('v2.6.4'), '2.6.4'), undefined)
assert.equal(resolveDownloadUrl(cliTemplate, release('nightly', ['easytier-linux-x86_64-v2.7.0Xzip'])), undefined)
})
test('API failure preserves stable and prerelease fallbacks without guessing nightly assets', () => {
assert.equal(resolveDownloadUrl(cliTemplate, null, '2.6.4'), 'https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-x86_64-v2.6.4.zip')
assert.equal(resolveDownloadUrl(guiTemplate, null, '2.7.0'), 'https://github.com/EasyTier/EasyTier/releases/download/v2.7.0/easytier-gui_2.7.0_x64-setup.exe')
assert.equal(resolveDownloadUrl(cliTemplate, null), undefined)
})
+29 -27
View File
@@ -1,40 +1,42 @@
import { onMounted, ref } from 'vue'
import type { Release } from './releaseUtils'
import { computed, onMounted, ref } from 'vue'
import { findPrerelease } from './releaseUtils'
export function useLatestVersion() {
const latestVersion = ref<string | null>(null)
const prereleaseVersion = ref<string | null>(null)
const latestRelease = ref<Release | null>(null)
const prereleaseRelease = ref<Release | null>(null)
const nightlyRelease = ref<Release | null>(null)
const latestVersion = computed(() => latestRelease.value?.tag_name.replace(/^v/, '') ?? null)
const prereleaseVersion = computed(() => prereleaseRelease.value?.tag_name.replace(/^v/, '') ?? null)
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
try {
const [latestRes, releasesRes] = await Promise.all([
fetch('https://api.github.com/repos/EasyTier/EasyTier/releases/latest'),
fetch('https://api.github.com/repos/EasyTier/EasyTier/releases?per_page=5'),
async function fetchRelease<T>(path: string): Promise<T> {
const response = await fetch(`https://api.github.com/repos/EasyTier/EasyTier/releases${path}`, { signal: AbortSignal.timeout(10000) })
if (!response.ok)
throw new Error('Failed to fetch releases from GitHub API')
return response.json()
}
const [latest, releases, nightly] = await Promise.allSettled([
fetchRelease<Release>('/latest'),
fetchRelease<Release[]>('?per_page=100'),
fetchRelease<Release>('/tags/nightly'),
])
if (!latestRes.ok || !releasesRes.ok) {
throw new Error('Failed to fetch releases from GitHub API')
}
if (latest.status === 'fulfilled')
latestRelease.value = latest.value
if (releases.status === 'fulfilled')
prereleaseRelease.value = findPrerelease(releases.value)
if (nightly.status === 'fulfilled')
nightlyRelease.value = nightly.value
const latestData = await latestRes.json() as { tag_name?: string }
const releases = await releasesRes.json() as Array<{ tag_name?: string, prerelease?: boolean }>
latestVersion.value = latestData.tag_name ?? null
prereleaseVersion.value = releases.find(item => item.prerelease)?.tag_name ?? null
}
catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
}
finally {
const failure = [latest, releases, nightly].find(result => result.status === 'rejected')
if (failure?.status === 'rejected')
error.value = failure.reason instanceof Error ? failure.reason.message : 'Unknown error'
loading.value = false
}
if (latestVersion.value?.startsWith('v'))
latestVersion.value = latestVersion.value.slice(1)
if (prereleaseVersion.value?.startsWith('v'))
prereleaseVersion.value = prereleaseVersion.value.slice(1)
})
return { latestVersion, prereleaseVersion, loading, error }
return { latestVersion, prereleaseVersion, latestRelease, prereleaseRelease, nightlyRelease, loading, error }
}