feat(download): 新增历史版本选择,重构下载全链路逻辑与UI

- 重构lib/github-releases.ts:统一预览版判断逻辑,新增支持`vX.Y.Z-N.beta`格式的beta标签,提取复用函数简化代码,将原有重复工具方法抽取至此统一管理并新增平台资产匹配工具
- 新增通用PlatformDownloadGrid多平台下载卡片组件,标准化多平台下载UI实现
- 重构两个下载API接口:统一返回格式并新增历史版本列表数据
- 重构/download和/launcher/join-beta页面:替换使用新的下载组件,新增历史版本选择功能
This commit is contained in:
2026-09-05 19:52:54 +08:00
parent 67b9d26460
commit 5997b1c7ea
7 changed files with 588 additions and 342 deletions
+82 -101
View File
@@ -1,35 +1,29 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
fetchReleases,
pickBetaRelease,
pickAsset,
buildDownloadUrl,
isBetaRelease,
RELEASES_PAGE,
} from "@/lib/github-releases";
/** /**
* 预览版(Beta)发布信息接口 * 预览版(Beta)发布信息接口
* *
* 数据源优先级: * 数据源优先级:
* 1. GitHub Releases API(直连)—— 主源,自动识别最新预览版 * 1. GitHub Releases API(直连 -> 镜像)—— 主源,自动识别最新预览版
* 2. GitHub Releases APIgh-proxy.com 镜像)—— 国内可达 * 2. 旧静态源 version.json —— 兜底(同时提供 Beta 协议 licence 文本)
* 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本)
* *
* 发布方案与仓库信息见《Electron Windows自动更新方案规划》 * 返回
* 仓库:dream-pep/koring-launcher * 顶层字段 = 最新预览版(licence 从旧静态源补充,下载需同意协议)
* 预览版 tagv{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease * versions = 历史预览版列表(新 -> 旧,按资产匹配平台;无 licence 数据,直接下载)
* 正式版 tagv{base}-{RUN_NUMBER}(如 v1.2.5-14),非 prerelease
* 安装包命名:koring-launcher-{version}-setup.exeelectron-builder artifactName
*/ */
const REPO = "dream-pep/koring-launcher";
const GITHUB_API = `https://api.github.com/repos/${REPO}/releases?per_page=100`;
const GITHUB_API_PROXY = `https://gh-proxy.com/https://api.github.com/repos/${REPO}/releases?per_page=100`;
const LEGACY_URL = const LEGACY_URL =
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"; "https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json";
const RELEASES_PAGE = `https://github.com/${REPO}/releases`;
const GH_HEADERS: Record<string, string> = { const HISTORY_LIMIT = 10;
"User-Agent": "koring-space-website",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
/** 预览版 tag 形态:v1.2.5-beta.16 */
const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i;
const EMPTY_LICENCE = { isNeedAgreat: "false", title: "", text: "", liceURL: "" }; const EMPTY_LICENCE = { isNeedAgreat: "false", title: "", text: "", liceURL: "" };
@@ -43,94 +37,62 @@ const EMPTY_APPS = {
const CACHE_TTL = 15 * 60 * 1000; const CACHE_TTL = 15 * 60 * 1000;
const cache = new Map<string, { time: number; value: unknown }>(); const cache = new Map<string, { time: number; value: unknown }>();
async function fetchJson(url: string, headers?: Record<string, string>) { function sortDesc(releases: any[]) {
const res = await fetch(url, { return [...(releases || [])].sort((a, b) =>
headers, String(b.published_at || b.created_at || "").localeCompare(
cache: "no-store", String(a.published_at || a.created_at || "")
signal: AbortSignal.timeout(15_000), )
});
if (!res.ok) return null;
try {
return await res.json();
} catch {
return null;
}
}
/** 依次尝试多个数据源,返回第一个成功的结果 */
async function trySources(
sources: Array<{ url: string; headers?: Record<string, string> }>
): Promise<unknown | null> {
for (const s of sources) {
try {
const json = await fetchJson(s.url, s.headers);
if (json !== null && json !== undefined) return json;
} catch {
// 继续尝试下一个源
}
}
return null;
}
/** 选出最新预览版 releaseprerelease 标记或 -beta.N tag */
function pickBetaRelease(releases: any[]) {
const betas = (releases || []).filter(
(r) =>
r &&
!r.draft &&
(r.prerelease === true || BETA_TAG.test(String(r.tag_name || "")))
); );
betas.sort((a, b) => {
const ta = String(a.published_at || a.created_at || "");
const tb = String(b.published_at || b.created_at || "");
return tb.localeCompare(ta);
});
return betas[0] || null;
} }
/** 按文件名匹配 release 资产 */ /**
function pickAsset(release: any, patterns: RegExp[]) { * 平台信息(DownlodeURL 为下载直链)
const assets = release?.assets || []; * licenceFor:给某平台提供 licence(最新版用旧源协议文本,历史版用空 = 无需同意)
for (const p of patterns) { */
const hit = assets.find((a: any) => p.test(String(a?.name || ""))); function buildPlatforms(release: any, licenceFor: (key: string) => any) {
if (hit) return hit; const tag = String(release.tag_name || "");
}
return null;
}
function buildPlatforms(release: any, legacyApp: any) {
const win = pickAsset(release, [/setup\.exe$/i, /\.exe$/i]);
const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]);
const lin = pickAsset(release, [
/\.appimage$/i,
/\.deb$/i,
/\.rpm$/i,
/\.tar\.gz$/i,
]);
const mk = (hit: any, key: string) => ({ const mk = (hit: any, key: string) => ({
isNoVersion: hit ? "false" : "true", isNoVersion: hit ? "false" : "true",
licence: legacyApp?.[key]?.licence ?? EMPTY_LICENCE, licence: licenceFor(key),
DownlodeURL: hit?.browser_download_url || "", DownlodeURL: hit ? buildDownloadUrl(tag, hit.name) : "",
}); });
return { return {
windows: mk(win, "windows"), windows: mk(pickAsset(release, [/setup\.exe$/i, /\.exe$/i]), "windows"),
macos: mk(mac, "macos"), macos: mk(pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]), "macos"),
linux: mk(lin, "linux"), linux: mk(
pickAsset(release, [/\.appimage$/i, /\.deb$/i, /\.rpm$/i, /\.tar\.gz$/i]),
"linux"
),
}; };
} }
function buildGithubPayload(release: any, legacy: any) { function baseEntry(release: any) {
const version = String(release.tag_name || "").replace(/^v/i, ""); const tag = String(release.tag_name || "");
return {
version: tag.replace(/^v/i, ""),
builddate: release.published_at || release.created_at || "",
tag,
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`,
releaseNotes: release.body || "",
};
}
function buildGithubPayload(beta: any, legacy: any) {
// 最新预览版:licence 用旧静态源文本(保持原有"需同意 Beta 协议"体验)
const licenceSrc = legacy?.app || null;
return { return {
source: "github", source: "github",
version, ...baseEntry(beta),
builddate: release.published_at || release.created_at || "", app: buildPlatforms(beta, (key) => licenceSrc?.[key]?.licence ?? EMPTY_LICENCE),
tag: release.tag_name, };
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${release.tag_name}`, }
releaseNotes: release.body || "",
app: buildPlatforms(release, legacy?.app), /** 历史预览版条目(无协议要求) */
function buildHistoryEntry(release: any) {
return {
...baseEntry(release),
app: buildPlatforms(release, () => EMPTY_LICENCE),
}; };
} }
@@ -152,20 +114,38 @@ export async function GET() {
} }
// 1) 主源:GitHub Releases API(直连 -> 镜像) // 1) 主源:GitHub Releases API(直连 -> 镜像)
const releases = (await trySources([ const releases = await fetchReleases();
{ url: GITHUB_API, headers: GH_HEADERS },
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
])) as any[] | null;
// 2) 兜底源:旧静态 JSON(也用于补充 Beta 协议 licence // 2) 兜底源:旧静态 JSON(也用于补充 Beta 协议 licence
const legacy = await trySources([{ url: LEGACY_URL }]); let legacy: any = null;
try {
const res = await fetch(LEGACY_URL, {
cache: "no-store",
signal: AbortSignal.timeout(15_000),
});
if (res.ok) legacy = await res.json();
} catch {
legacy = null;
}
let payload: unknown; let payload: unknown;
if (Array.isArray(releases) && releases.length > 0) { if (Array.isArray(releases) && releases.length > 0) {
const beta = pickBetaRelease(releases); const beta = pickBetaRelease(releases);
if (beta) { if (beta) {
payload = buildGithubPayload(beta, legacy); const betaTag = String(beta.tag_name || "");
const history = sortDesc(releases)
.filter(
(r) =>
isBetaRelease(r) && String(r.tag_name || "") !== betaTag
)
.slice(0, HISTORY_LIMIT)
.map(buildHistoryEntry);
payload = {
...buildGithubPayload(beta, legacy),
versions: history,
};
} else { } else {
// GitHub 可达但没有任何预览版 // GitHub 可达但没有任何预览版
payload = { payload = {
@@ -174,6 +154,7 @@ export async function GET() {
version: "", version: "",
builddate: "", builddate: "",
app: EMPTY_APPS, app: EMPTY_APPS,
versions: [],
}; };
} }
} else if (legacy) { } else if (legacy) {
+73 -42
View File
@@ -6,14 +6,16 @@ import {
pickAsset, pickAsset,
buildDownloadUrl, buildDownloadUrl,
fetchYmlForRelease, fetchYmlForRelease,
isBetaRelease,
RELEASES_PAGE, RELEASES_PAGE,
} from "@/lib/github-releases"; } from "@/lib/github-releases";
/** /**
* 下载页版本信息接口 * 下载页版本信息接口
* 同时返回 正式版(latest.yml)与 预览版(latest-beta.yml / latest.yml)两个渠道, * 返回:
* 版本 / 安装包文件名 / 大小 / SHA512 / 发布时间 均取自 electron-builder 发布的 yml * stable / preview —— 正式版与预览版最新一条(含 latest.yml 数据:文件名/大小/SHA512/发布时间,
* 与客户端自动更新机制(electron-updater)保持一致 * electron-updater 自动更新机制一致
* versions —— 历史版本列表(新 -> 旧,按资产匹配平台,无 yml 数据)
*/ */
const CACHE_TTL = 15 * 60 * 1000; const CACHE_TTL = 15 * 60 * 1000;
@@ -22,11 +24,34 @@ const cache = new Map<string, { time: number; value: unknown }>();
const LEGACY_URL = const LEGACY_URL =
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"; "https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json";
async function buildChannelAsync( const HISTORY_LIMIT = 20;
release: any,
ymlNames: string[], const EXE_PATTERNS = [/setup\.exe$/i, /\.exe$/i];
fallbackExePatterns: RegExp[] const MAC_PATTERNS = [/\.dmg$/i, /\.zip$/i, /\.pkg$/i];
) { const LINUX_PATTERNS = [/\.appimage$/i, /\.deb$/i, /\.rpm$/i, /\.tar\.gz$/i];
/** 按发布资产匹配三个平台(无 yml 数据版本用) */
function platformsFromRelease(release: any) {
const tag = String(release.tag_name || "");
const mk = (hit: any) =>
hit ? { name: hit.name, url: buildDownloadUrl(tag, hit.name) } : null;
return {
windows: mk(pickAsset(release, EXE_PATTERNS)),
macos: mk(pickAsset(release, MAC_PATTERNS)),
linux: mk(pickAsset(release, LINUX_PATTERNS)),
};
}
function sortDesc(releases: any[]) {
return [...(releases || [])].sort((a, b) =>
String(b.published_at || b.created_at || "").localeCompare(
String(a.published_at || a.created_at || "")
)
);
}
/** 最新渠道条目(带 latest.yml / latest-beta.yml 数据) */
async function buildChannelAsync(release: any, ymlNames: string[]) {
if (!release) return null; if (!release) return null;
const tag = String(release.tag_name || ""); const tag = String(release.tag_name || "");
const version = tag.replace(/^v/i, ""); const version = tag.replace(/^v/i, "");
@@ -34,40 +59,44 @@ async function buildChannelAsync(
// 主源:latest.yml / latest-beta.ymlelectron-updater 权威数据) // 主源:latest.yml / latest-beta.ymlelectron-updater 权威数据)
const yml = await fetchYmlForRelease(release, ymlNames); const yml = await fetchYmlForRelease(release, ymlNames);
const ymlFile = yml?.files?.[0]; const ymlFile = yml?.files?.[0];
const fileName = ymlFile?.url || yml?.path || ""; const plat = platformsFromRelease(release);
const windowsName =
ymlFile?.url || yml?.path || plat.windows?.name || "";
const windowsFile = fileName const windows = windowsName
? { name: fileName } ? {
: pickAsset(release, fallbackExePatterns); name: windowsName,
url: buildDownloadUrl(tag, windowsName),
const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]); size: ymlFile?.size ?? undefined,
const lin = pickAsset(release, [ sha512: yml?.sha512 || ymlFile?.sha512 || "",
/\.appimage$/i, }
/\.deb$/i, : null;
/\.rpm$/i,
/\.tar\.gz$/i,
]);
return { return {
version, version,
tag, tag,
releaseDate: yml?.releaseDate || release.published_at || release.created_at || "", channel: isBetaRelease(release) ? "preview" : "stable",
releaseDate:
yml?.releaseDate || release.published_at || release.created_at || "",
releaseNotes: release.body || "", releaseNotes: release.body || "",
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`, htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`,
windows: windowsFile windows,
? { macos: plat.macos,
name: windowsFile.name, linux: plat.linux,
url: buildDownloadUrl(tag, windowsFile.name), };
size: ymlFile?.size ?? undefined,
sha512: yml?.sha512 || ymlFile?.sha512 || "",
} }
: null,
macos: mac /** 历史版本条目(资产匹配,不含 yml) */
? { name: mac.name, url: buildDownloadUrl(tag, mac.name) } function buildHistoryEntry(release: any) {
: null, const tag = String(release.tag_name || "");
linux: lin return {
? { name: lin.name, url: buildDownloadUrl(tag, lin.name) } version: tag.replace(/^v/i, ""),
: null, tag,
channel: isBetaRelease(release) ? "preview" : "stable",
releaseDate: release.published_at || release.created_at || "",
releaseNotes: release.body || "",
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`,
...platformsFromRelease(release),
}; };
} }
@@ -76,6 +105,7 @@ function buildLegacyPayload(legacy: any) {
const preview: any = { const preview: any = {
version: String(legacy?.version || ""), version: String(legacy?.version || ""),
tag: "", tag: "",
channel: "preview",
releaseDate: "", releaseDate: "",
releaseNotes: legacy?.aboutversion?.about || "", releaseNotes: legacy?.aboutversion?.about || "",
htmlUrl: RELEASES_PAGE, htmlUrl: RELEASES_PAGE,
@@ -90,7 +120,7 @@ function buildLegacyPayload(legacy: any) {
macos: null, macos: null,
linux: null, linux: null,
}; };
return { source: "legacy", stable: null, preview }; return { source: "legacy", stable: null, preview, versions: [preview] };
} }
export async function GET() { export async function GET() {
@@ -108,15 +138,16 @@ export async function GET() {
const betaRelease = pickBetaRelease(releases); const betaRelease = pickBetaRelease(releases);
const [stable, preview] = await Promise.all([ const [stable, preview] = await Promise.all([
buildChannelAsync(stableRelease, ["latest.yml"], [/setup\.exe$/i, /\.exe$/i]), buildChannelAsync(stableRelease, ["latest.yml"]),
buildChannelAsync( buildChannelAsync(betaRelease, ["latest-beta.yml", "latest.yml"]),
betaRelease,
["latest-beta.yml", "latest.yml"],
[/setup\.exe$/i, /\.exe$/i]
),
]); ]);
payload = { source: "github", stable, preview }; const versions = sortDesc(releases)
.filter((r) => r && !r.draft)
.slice(0, HISTORY_LIMIT)
.map(buildHistoryEntry);
payload = { source: "github", stable, preview, versions };
} else { } else {
// GitHub 不可达 -> 旧静态源兜底(仅预览渠道) // GitHub 不可达 -> 旧静态源兜底(仅预览渠道)
let legacy: any = null; let legacy: any = null;
+137 -52
View File
@@ -1,16 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Download,
Package, Package,
Calendar, Calendar,
HardDrive, HardDrive,
ShieldCheck, ShieldCheck,
Copy, History,
Check, Monitor,
Apple,
Terminal,
Loader2, Loader2,
AlertCircle, AlertCircle,
ArrowUpRight, ArrowUpRight,
@@ -20,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ReleaseNotes } from "@/components/release-notes"; import { ReleaseNotes } from "@/components/release-notes";
import { PlatformDownloadGrid } from "@/components/platform-download-grid";
import { import {
DOWNLOAD_SOURCES, DOWNLOAD_SOURCES,
resolveDownloadUrl, resolveDownloadUrl,
@@ -36,6 +38,7 @@ interface ChannelPlatform {
interface ChannelData { interface ChannelData {
version: string; version: string;
tag?: string; tag?: string;
channel?: "stable" | "preview";
releaseDate?: string; releaseDate?: string;
releaseNotes?: string; releaseNotes?: string;
htmlUrl?: string; htmlUrl?: string;
@@ -48,6 +51,7 @@ interface DownloadData {
source?: "github" | "legacy"; source?: "github" | "legacy";
stable: ChannelData | null; stable: ChannelData | null;
preview: ChannelData | null; preview: ChannelData | null;
versions: ChannelData[];
} }
const CHANNEL_META = [ const CHANNEL_META = [
@@ -55,11 +59,15 @@ const CHANNEL_META = [
{ key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" }, { key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" },
]; ];
type Selection =
| { kind: "latest"; channel: "stable" | "preview" }
| { kind: "version"; version: string };
export default function DownloadPage() { export default function DownloadPage() {
const [data, setData] = useState<DownloadData | null>(null); const [data, setData] = useState<DownloadData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [channel, setChannel] = useState<"stable" | "preview">("stable"); const [selection, setSelection] = useState<Selection | null>(null);
const [downloadSource, setDownloadSource] = const [downloadSource, setDownloadSource] =
useState<DownloadSourceKey>("github"); useState<DownloadSourceKey>("github");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -80,23 +88,43 @@ export default function DownloadPage() {
}); });
}, []); }, []);
// 当前渠道:选中的不存在时自动回退到可用渠道 // 汇总全部版本(最新渠道条目带 yml 数据,覆盖历史同名条目)
const activeKey = const allVersions = useMemo<ChannelData[]>(() => {
data && data[channel] ? channel : data?.stable ? "stable" : "preview"; if (!data) return [];
const active = data ? data[activeKey] : null; const map = new Map<string, ChannelData>();
const win = active?.windows || null; for (const v of data.versions || []) map.set(v.version, v);
if (data.stable) map.set(data.stable.version, data.stable);
if (data.preview) map.set(data.preview.version, data.preview);
return [...map.values()];
}, [data]);
// 默认选中:正式版(无则预览版)
const defaultLatestChannel: "stable" | "preview" =
data?.stable ? "stable" : "preview";
const effectiveSelection: Selection =
selection ?? { kind: "latest", channel: defaultLatestChannel };
const active = useMemo<ChannelData | null>(() => {
if (!data) return null;
if (effectiveSelection.kind === "version") {
return (
allVersions.find((v) => v.version === effectiveSelection.version) || null
);
}
return data[effectiveSelection.channel];
}, [data, effectiveSelection, allVersions]);
const isGithub = data?.source === "github"; const isGithub = data?.source === "github";
const win = active?.windows || null;
const hasAnyDownload = !!(
active && (active.windows?.url || active.macos?.url || active.linux?.url)
);
const formatSize = (bytes?: number) => const formatSize = (bytes?: number) =>
bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : "未知"; bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : "未知";
const formatDate = (d?: string) => const formatDate = (d?: string) =>
d ? new Date(d).toLocaleDateString("zh-CN") : "未知"; d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
const handleDownload = () => {
if (!win?.url) return;
window.open(resolveDownloadUrl(downloadSource, win.url), "_blank");
};
const copySha = async () => { const copySha = async () => {
if (!win?.sha512) return; if (!win?.sha512) return;
try { try {
@@ -108,6 +136,11 @@ export default function DownloadPage() {
} }
}; };
const handlePlatformDownload = (url?: string | null) => {
if (!url) return;
window.open(resolveDownloadUrl(downloadSource, url), "_blank");
};
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
@@ -153,7 +186,14 @@ export default function DownloadPage() {
); );
} }
const meta = CHANNEL_META.find((c) => c.key === activeKey)!; const activeChannelLabel =
active.channel === "preview"
? CHANNEL_META[1].label
: CHANNEL_META[0].label;
const activeIsLatest = effectiveSelection.kind === "latest";
const activeDesc = activeIsLatest
? CHANNEL_META.find((c) => c.key === effectiveSelection.channel)?.desc
: "历史版本,安装包与更新内容保留展示";
return ( return (
<div className="flex flex-col w-full h-full gap-10 pb-24"> <div className="flex flex-col w-full h-full gap-10 pb-24">
@@ -177,32 +217,60 @@ export default function DownloadPage() {
)} )}
</div> </div>
{/* 渠道切换 */} {/* 渠道切换 + 历史版本 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{CHANNEL_META.map((c) => { {CHANNEL_META.map((c) => {
const d = c.key === "stable" ? data.stable : data.preview; const d = c.key === "stable" ? data.stable : data.preview;
const isActive =
effectiveSelection.kind === "latest" &&
effectiveSelection.channel === c.key;
return ( return (
<button <button
key={c.key} key={c.key}
type="button" type="button"
disabled={!d} disabled={!d}
onClick={() => setChannel(c.key)} onClick={() => setSelection({ kind: "latest", channel: c.key })}
className={cn( className={cn(
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors", "rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
activeKey === c.key isActive
? "border-primary bg-primary/10 text-primary" ? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground", : "border-border text-muted-foreground hover:text-foreground",
!d && "opacity-40 cursor-not-allowed" !d && "opacity-40 cursor-not-allowed"
)} )}
> >
{c.label} {c.label}
{d && <span className="ml-1.5 text-xs opacity-70">· {d.version}</span>} {d && (
<span className="ml-1.5 text-xs opacity-70">· {d.version}</span>
)}
</button> </button>
); );
})} })}
</div> </div>
{/* 渠道信息卡 */} {allVersions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<History className="size-4 shrink-0" />
<span className="shrink-0"></span>
<select
value={active.version}
onChange={(e) => setSelection({ kind: "version", version: e.target.value })}
className="max-w-[16rem] rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary"
>
{allVersions.map((v) => (
<option key={v.version} value={v.version}>
{v.version}
{v.channel === "preview" ? "(预览版)" : "(正式版)"}
{" · "}
{formatDate(v.releaseDate)}
</option>
))}
</select>
</label>
)}
</div>
{/* 版本信息卡 */}
<div className="rounded-2xl border border-border/50 bg-background/60 backdrop-blur-xl overflow-hidden"> <div className="rounded-2xl border border-border/50 bg-background/60 backdrop-blur-xl overflow-hidden">
<div className="p-6 md:p-8"> <div className="p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -215,10 +283,10 @@ export default function DownloadPage() {
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
{active.version} {active.version}
</h2> </h2>
<Badge variant="outline">{meta.label}</Badge> <Badge variant="outline">{activeChannelLabel}</Badge>
</div> </div>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{meta.desc} {activeDesc}
</p> </p>
</div> </div>
</div> </div>
@@ -249,33 +317,35 @@ export default function DownloadPage() {
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3"> <div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
<HardDrive className="size-5 text-primary shrink-0" /> <HardDrive className="size-5 text-primary shrink-0" />
<div> <div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<p className="font-semibold text-sm">{formatSize(win?.size)}</p> <p className="font-semibold text-sm">{formatSize(win?.size)}</p>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={copySha} onClick={copySha}
title="点击复制 SHA512" disabled={!win?.sha512}
className="flex items-center gap-3 rounded-xl border border-border/50 p-3 text-left transition-colors hover:border-primary/50" title={win?.sha512 ? "点击复制 SHA512" : undefined}
className={cn(
"flex items-center gap-3 rounded-xl border border-border/50 p-3 text-left transition-colors",
win?.sha512 && "hover:border-primary/50"
)}
> >
<ShieldCheck className="size-5 text-primary shrink-0" /> <ShieldCheck className="size-5 text-primary shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
SHA512{copied ? "(已复制)" : "(点击复制)"} SHA512{win?.sha512 && (copied ? "(已复制)" : "(点击复制)")}
</p> </p>
<p className="font-mono text-xs text-muted-foreground truncate"> <p className="font-mono text-xs text-muted-foreground truncate">
{win?.sha512 || "未"} {win?.sha512 || "未提供"}
</p> </p>
</div> </div>
</button> </button>
</div> </div>
{/* 下载 */} {/* 下载源切换 */}
{win ? ( {isGithub && hasAnyDownload && (
<div className="mt-6 flex flex-col gap-4"> <div className="mt-6 flex items-center gap-2 flex-wrap">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
{DOWNLOAD_SOURCES.map((s) => ( {DOWNLOAD_SOURCES.map((s) => (
<button <button
@@ -293,31 +363,46 @@ export default function DownloadPage() {
</button> </button>
))} ))}
</div> </div>
<span className="text-xs text-muted-foreground">
{win.name}
</span>
</div>
<Button size="lg" onClick={handleDownload}>
<Download className="size-4" />
Windows {active.version}
</Button>
{!active.macos && !active.linux && (
<p className="text-xs text-muted-foreground">
macOS / Linux
</p>
)} )}
{/* 平台下载(按钮常驻,无版本则禁用) */}
<div className="mt-6">
<PlatformDownloadGrid
platforms={[
{
key: "windows",
label: "Windows",
icon: Monitor,
download: active.windows
? { name: active.windows.name, url: active.windows.url }
: null,
},
{
key: "macos",
label: "macOS",
icon: Apple,
download: active.macos
? { name: active.macos.name, url: active.macos.url }
: null,
},
{
key: "linux",
label: "Linux",
icon: Terminal,
download: active.linux
? { name: active.linux.name, url: active.linux.url }
: null,
},
]}
onDownload={(p) => handlePlatformDownload(p.download?.url)}
/>
</div> </div>
) : (
<div className="mt-6 rounded-xl border border-border/30 p-4 text-center text-sm text-muted-foreground">
</div>
)}
</div> </div>
{/* 更新内容 */} {/* 此版本的变更 */}
{active.releaseNotes && ( {active.releaseNotes && (
<div className="border-t border-border/50 p-6 md:p-8"> <div className="border-t border-border/50 p-6 md:p-8">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-4">
<FileText className="size-5 text-primary" /> <FileText className="size-5 text-primary" />
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
</div> </div>
+119 -81
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -12,9 +12,6 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
Monitor,
Apple,
Terminal,
Loader2, Loader2,
AlertCircle, AlertCircle,
Package, Package,
@@ -24,10 +21,14 @@ import {
PackageOpen, PackageOpen,
ArrowUpRight, ArrowUpRight,
FileText, FileText,
Download, History,
Monitor,
Apple,
Terminal,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ReleaseNotes } from "@/components/release-notes"; import { ReleaseNotes } from "@/components/release-notes";
import { PlatformDownloadGrid } from "@/components/platform-download-grid";
import { import {
DOWNLOAD_SOURCES, DOWNLOAD_SOURCES,
resolveDownloadUrl, resolveDownloadUrl,
@@ -47,11 +48,27 @@ interface PlatformInfo {
DownlodeURL: string; DownlodeURL: string;
} }
interface AppInfo {
windows: PlatformInfo;
macos: PlatformInfo;
linux: PlatformInfo;
}
interface AboutVersion { interface AboutVersion {
about: string; about: string;
"about-list": Record<string, string>; "about-list": Record<string, string>;
} }
/** 历史预览版条目(与顶层字段同构,可覆盖到页面上) */
interface BetaVersionItem {
version: string;
builddate: string;
tag?: string;
htmlUrl?: string;
releaseNotes?: string;
app: AppInfo;
}
interface BetaData { interface BetaData {
version: string; version: string;
builddate: string; builddate: string;
@@ -62,19 +79,13 @@ interface BetaData {
releaseNotes?: string; releaseNotes?: string;
/** GitHub 可达但没有预览版时的标记 */ /** GitHub 可达但没有预览版时的标记 */
noRelease?: boolean; noRelease?: boolean;
app: { app: AppInfo;
windows: PlatformInfo;
macos: PlatformInfo;
linux: PlatformInfo;
};
aboutversion?: AboutVersion; aboutversion?: AboutVersion;
/** 历史预览版列表(新 -> 旧) */
versions?: BetaVersionItem[];
} }
const platforms = [ const PLATFORM_SLOTS = ["windows", "macos", "linux"] as const;
{ key: "windows" as const, label: "Windows", icon: Monitor },
{ key: "macos" as const, label: "macOS", icon: Apple },
{ key: "linux" as const, label: "Linux", icon: Terminal },
];
export default function JoinBetaPage() { export default function JoinBetaPage() {
const [data, setData] = useState<BetaData | null>(null); const [data, setData] = useState<BetaData | null>(null);
@@ -83,6 +94,8 @@ export default function JoinBetaPage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null); const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github"); const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
/** null = 最新预览版(顶层数据),否则为 data.versions 中的历史版本号 */
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/beta-version") fetch("/api/beta-version")
@@ -100,6 +113,25 @@ export default function JoinBetaPage() {
}); });
}, []); }, []);
// 当前展示的版本:选中的历史版本会覆盖顶层字段(保留 source/versions 等)
const active = useMemo<BetaData | null>(() => {
if (!data) return null;
if (selectedVersion) {
const item = data.versions?.find((v) => v.version === selectedVersion);
if (item) return { ...data, ...item };
}
return data;
}, [data, selectedVersion]);
const isGithub = active?.source === "github";
const hasAnyDownload = !!(
active &&
PLATFORM_SLOTS.some((k) => active.app[k].DownlodeURL)
);
const formatDate = (d?: string) =>
d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
const handleDownload = (platform: PlatformInfo) => { const handleDownload = (platform: PlatformInfo) => {
if (platform.licence.isNeedAgreat === "true") { if (platform.licence.isNeedAgreat === "true") {
setActivePlatform(platform); setActivePlatform(platform);
@@ -168,6 +200,8 @@ export default function JoinBetaPage() {
); );
} }
if (!active) return null;
return ( return (
<div className="flex flex-col w-full h-full gap-10 pb-24"> <div className="flex flex-col w-full h-full gap-10 pb-24">
{/* Header */} {/* Header */}
@@ -182,15 +216,15 @@ export default function JoinBetaPage() {
Koring Launcher Beta Koring Launcher Beta
</p> </p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{data.source === "github" ? ( {isGithub ? (
<> <>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GitBranch className="size-3" /> <GitBranch className="size-3" />
GitHub Releases GitHub Releases
</span> </span>
{data.htmlUrl && ( {active.htmlUrl && (
<a <a
href={data.htmlUrl} href={active.htmlUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-primary underline underline-offset-3 hover:text-primary/80" className="flex items-center gap-1 text-primary underline underline-offset-3 hover:text-primary/80"
@@ -217,12 +251,12 @@ export default function JoinBetaPage() {
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<p className="font-semibold text-lg">{data.version}</p> <p className="font-semibold text-lg">{active.version}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary/10"> <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary/10">
{data.source === "github" ? ( {isGithub ? (
<Calendar className="size-5 text-primary" /> <Calendar className="size-5 text-primary" />
) : ( ) : (
<Hash className="size-5 text-primary" /> <Hash className="size-5 text-primary" />
@@ -230,25 +264,42 @@ export default function JoinBetaPage() {
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{data.source === "github" ? "发布时间" : "编译号"} {isGithub ? "发布时间" : "编译号"}
</p> </p>
<p className="font-semibold text-lg"> <p className="font-semibold text-lg">
{data.source === "github" {isGithub ? formatDate(active.builddate) : active.builddate}
? data.builddate
? new Date(data.builddate).toLocaleDateString("zh-CN")
: "未知"
: data.builddate}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* 历史版本选择 */}
{data.versions && data.versions.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<History className="size-4 shrink-0" />
<span className="shrink-0"></span>
<select
value={selectedVersion ?? ""}
onChange={(e) => setSelectedVersion(e.target.value || null)}
className="max-w-[16rem] rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary"
>
<option value=""> {data.version}</option>
{data.versions.map((v) => (
<option key={v.version} value={v.version}>
{v.version}
{" · "}
{formatDate(v.builddate)}
</option>
))}
</select>
</label>
)}
{/* Platform Cards */} {/* Platform Cards */}
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold"></h2>
{data.source === "github" && {isGithub && hasAnyDownload && (
platforms.some(({ key }) => !!data.app[key].DownlodeURL) && (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
{DOWNLOAD_SOURCES.map((s) => ( {DOWNLOAD_SOURCES.map((s) => (
@@ -269,73 +320,60 @@ export default function JoinBetaPage() {
</div> </div>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <PlatformDownloadGrid
{platforms.map(({ key, label, icon: Icon }) => { platforms={[
const platform = data.app[key]; {
const noVersion = platform.isNoVersion === "true"; key: "windows",
label: "Windows",
return ( icon: Monitor,
<div download: active.app.windows.DownlodeURL
key={key} ? { url: active.app.windows.DownlodeURL }
className={cn( : null,
"rounded-2xl border p-6 flex flex-col gap-4 transition-all duration-300", hint: "未提供对应版本",
noVersion },
? "border-border/30 opacity-60" {
: "border-border/50 bg-background/60 backdrop-blur-xl hover:scale-[1.02]" key: "macos",
)} label: "macOS",
> icon: Apple,
<div className="flex items-center gap-3"> download: active.app.macos.DownlodeURL
<div ? { url: active.app.macos.DownlodeURL }
className={cn( : null,
"flex items-center justify-center w-10 h-10 rounded-xl", hint: "未提供对应版本",
noVersion ? "bg-muted" : "bg-primary/10" },
)} {
> key: "linux",
<Icon label: "Linux",
className={cn( icon: Terminal,
"size-5", download: active.app.linux.DownlodeURL
noVersion ? "text-muted-foreground" : "text-primary" ? { url: active.app.linux.DownlodeURL }
)} : null,
hint: "未提供对应版本",
},
]}
onDownload={(slot) => {
const info = active.app[slot.key as keyof AppInfo];
if (info?.DownlodeURL) handleDownload(info);
}}
/> />
</div> </div>
<span className="font-semibold text-lg">{label}</span>
</div>
{noVersion ? (
<p className="text-sm text-muted-foreground">
</p>
) : (
<Button
className="w-full mt-auto"
onClick={() => handleDownload(platform)}
>
<Download className="size-4" />
</Button>
)}
</div>
);
})}
</div>
</div>
{/* Release Notes / About This Version */} {/* Release Notes / About This Version */}
<div className="rounded-2xl border border-border/50 p-6 md:p-8 bg-background/60 backdrop-blur-xl"> <div className="rounded-2xl border border-border/50 p-6 md:p-8 bg-background/60 backdrop-blur-xl">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<FileText className="size-5 text-primary" /> <FileText className="size-5 text-primary" />
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{data.releaseNotes ? "版本更新内容" : "关于此版本"} {active.releaseNotes ? "版本更新内容" : "关于此版本"}
</h2> </h2>
</div> </div>
{data.releaseNotes ? ( {active.releaseNotes ? (
<ReleaseNotes text={data.releaseNotes} /> <ReleaseNotes text={active.releaseNotes} />
) : data.aboutversion ? ( ) : active.aboutversion ? (
<> <>
<p className="text-muted-foreground leading-relaxed mb-4"> <p className="text-muted-foreground leading-relaxed mb-4">
{data.aboutversion.about} {active.aboutversion.about}
</p> </p>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{Object.entries(data.aboutversion["about-list"]).map( {Object.entries(active.aboutversion["about-list"]).map(
([key, text]) => ( ([key, text]) => (
<li key={key} className="flex items-start gap-2 text-sm"> <li key={key} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 size-1.5 rounded-full bg-primary shrink-0" /> <span className="mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
+96
View File
@@ -0,0 +1,96 @@
import React from "react";
import {
Monitor,
Apple,
Terminal,
Download,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
* 平台下载网格(Windows / macOS / Linux 三卡)
* 三个平台按钮常驻;某平台没有对应安装包时按钮置灰禁用(不删除),
* 并在卡内提示"暂未提供此平台版本"。
*/
export interface PlatformDownload {
/** 展示文件名(可选) */
name?: string;
url: string;
}
export interface PlatformSlot {
key: string;
label: string;
icon: LucideIcon;
/** url 为空/缺失 = 该平台暂无版本,按钮禁用 */
download?: PlatformDownload | null;
/** 无版本时的提示文案 */
hint?: string;
}
interface PlatformDownloadGridProps {
platforms: PlatformSlot[];
onDownload: (slot: PlatformSlot) => void;
className?: string;
}
export function PlatformDownloadGrid({
platforms,
onDownload,
className,
}: PlatformDownloadGridProps) {
return (
<div className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4", className)}>
{platforms.map((p) => {
const available = !!p.download?.url;
return (
<div
key={p.key}
className={cn(
"rounded-2xl border p-6 flex flex-col gap-4 transition-all duration-300",
available
? "border-border/50 bg-background/60 backdrop-blur-xl hover:scale-[1.02]"
: "border-border/30 opacity-60"
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-10 h-10 rounded-xl",
available ? "bg-primary/10" : "bg-muted"
)}
>
<p.icon
className={cn(
"size-5",
available ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<span className="font-semibold text-lg">{p.label}</span>
</div>
<div className="mt-auto">
<Button
className="w-full"
disabled={!available}
title={available ? undefined : "该平台暂未提供安装包"}
onClick={() => available && onDownload(p)}
>
<Download className="size-4" />
</Button>
{!available && (
<p className="mt-2 text-center text-xs text-muted-foreground">
{p.hint || "暂未提供此平台版本"}
</p>
)}
</div>
</div>
);
})}
</div>
);
}
File diff suppressed because one or more lines are too long
+24 -9
View File
@@ -20,8 +20,28 @@ const GH_HEADERS: Record<string, string> = {
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
}; };
/** 预览版 tag 形态:v1.2.5-beta.16 */ /**
export const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i; * 预览版 tag 形态(兼容两代方案):
* v1.2.5-beta.16 (旧,-beta.N 尾号)
* v1.2.6-29.beta(新,-N.beta 尾号)
*/
export const BETA_TAG =
/^v?\d+\.\d+\.\d+(-beta\.\d+|-\d+\.beta)$/i;
/** 判断 tag 是否为预览版(不要求整串匹配,供 history 等筛选使用) */
export function isBetaTag(tag: string) {
return /(-beta\.\d+|-\d+\.beta)$/i.test(String(tag || ""));
}
/** 判断 release 是否为预览版:优先 prerelease 标记,其次按 tag 形态 */
export function isBetaRelease(release: any) {
return !!(
release &&
!release.draft &&
(release.prerelease === true ||
isBetaTag(String(release.tag_name || "")))
);
}
interface FetchResult { interface FetchResult {
ok: boolean; ok: boolean;
@@ -80,14 +100,9 @@ function byPublishedDesc(a: any, b: any) {
return tb.localeCompare(ta); return tb.localeCompare(ta);
} }
/** 最新预览版 releaseprerelease 标记或 -beta.N tag */ /** 最新预览版 releaseprerelease 标记或预览 tag */
export function pickBetaRelease(releases: any[]) { export function pickBetaRelease(releases: any[]) {
const betas = (releases || []).filter( const betas = (releases || []).filter((r) => isBetaRelease(r));
(r) =>
r &&
!r.draft &&
(r.prerelease === true || BETA_TAG.test(String(r.tag_name || "")))
);
betas.sort(byPublishedDesc); betas.sort(byPublishedDesc);
return betas[0] || null; return betas[0] || null;
} }