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
+139 -101
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -12,9 +12,6 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import {
Monitor,
Apple,
Terminal,
Loader2,
AlertCircle,
Package,
@@ -24,10 +21,14 @@ import {
PackageOpen,
ArrowUpRight,
FileText,
Download,
History,
Monitor,
Apple,
Terminal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ReleaseNotes } from "@/components/release-notes";
import { PlatformDownloadGrid } from "@/components/platform-download-grid";
import {
DOWNLOAD_SOURCES,
resolveDownloadUrl,
@@ -47,11 +48,27 @@ interface PlatformInfo {
DownlodeURL: string;
}
interface AppInfo {
windows: PlatformInfo;
macos: PlatformInfo;
linux: PlatformInfo;
}
interface AboutVersion {
about: string;
"about-list": Record<string, string>;
}
/** 历史预览版条目(与顶层字段同构,可覆盖到页面上) */
interface BetaVersionItem {
version: string;
builddate: string;
tag?: string;
htmlUrl?: string;
releaseNotes?: string;
app: AppInfo;
}
interface BetaData {
version: string;
builddate: string;
@@ -62,19 +79,13 @@ interface BetaData {
releaseNotes?: string;
/** GitHub 可达但没有预览版时的标记 */
noRelease?: boolean;
app: {
windows: PlatformInfo;
macos: PlatformInfo;
linux: PlatformInfo;
};
app: AppInfo;
aboutversion?: AboutVersion;
/** 历史预览版列表(新 -> 旧) */
versions?: BetaVersionItem[];
}
const platforms = [
{ key: "windows" as const, label: "Windows", icon: Monitor },
{ key: "macos" as const, label: "macOS", icon: Apple },
{ key: "linux" as const, label: "Linux", icon: Terminal },
];
const PLATFORM_SLOTS = ["windows", "macos", "linux"] as const;
export default function JoinBetaPage() {
const [data, setData] = useState<BetaData | null>(null);
@@ -83,6 +94,8 @@ export default function JoinBetaPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
/** null = 最新预览版(顶层数据),否则为 data.versions 中的历史版本号 */
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
useEffect(() => {
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) => {
if (platform.licence.isNeedAgreat === "true") {
setActivePlatform(platform);
@@ -168,6 +200,8 @@ export default function JoinBetaPage() {
);
}
if (!active) return null;
return (
<div className="flex flex-col w-full h-full gap-10 pb-24">
{/* Header */}
@@ -182,15 +216,15 @@ export default function JoinBetaPage() {
Koring Launcher Beta
</p>
<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">
<GitBranch className="size-3" />
GitHub Releases
</span>
{data.htmlUrl && (
{active.htmlUrl && (
<a
href={data.htmlUrl}
href={active.htmlUrl}
target="_blank"
rel="noopener noreferrer"
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>
<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 className="flex items-center gap-3">
<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" />
) : (
<Hash className="size-5 text-primary" />
@@ -230,93 +264,97 @@ export default function JoinBetaPage() {
</div>
<div>
<p className="text-xs text-muted-foreground">
{data.source === "github" ? "发布时间" : "编译号"}
{isGithub ? "发布时间" : "编译号"}
</p>
<p className="font-semibold text-lg">
{data.source === "github"
? data.builddate
? new Date(data.builddate).toLocaleDateString("zh-CN")
: "未知"
: data.builddate}
{isGithub ? formatDate(active.builddate) : active.builddate}
</p>
</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 */}
<div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<h2 className="text-xl font-semibold"></h2>
{data.source === "github" &&
platforms.some(({ key }) => !!data.app[key].DownlodeURL) && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground"></span>
{DOWNLOAD_SOURCES.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setDownloadSource(s.key)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
downloadSource === s.key
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{s.label}
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{platforms.map(({ key, label, icon: Icon }) => {
const platform = data.app[key];
const noVersion = platform.isNoVersion === "true";
return (
<div
key={key}
className={cn(
"rounded-2xl border p-6 flex flex-col gap-4 transition-all duration-300",
noVersion
? "border-border/30 opacity-60"
: "border-border/50 bg-background/60 backdrop-blur-xl hover:scale-[1.02]"
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-10 h-10 rounded-xl",
noVersion ? "bg-muted" : "bg-primary/10"
)}
>
<Icon
className={cn(
"size-5",
noVersion ? "text-muted-foreground" : "text-primary"
)}
/>
</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>
);
})}
{isGithub && hasAnyDownload && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground"></span>
{DOWNLOAD_SOURCES.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setDownloadSource(s.key)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
downloadSource === s.key
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{s.label}
</button>
))}
</div>
)}
</div>
<PlatformDownloadGrid
platforms={[
{
key: "windows",
label: "Windows",
icon: Monitor,
download: active.app.windows.DownlodeURL
? { url: active.app.windows.DownlodeURL }
: null,
hint: "未提供对应版本",
},
{
key: "macos",
label: "macOS",
icon: Apple,
download: active.app.macos.DownlodeURL
? { url: active.app.macos.DownlodeURL }
: null,
hint: "未提供对应版本",
},
{
key: "linux",
label: "Linux",
icon: Terminal,
download: active.app.linux.DownlodeURL
? { url: active.app.linux.DownlodeURL }
: null,
hint: "未提供对应版本",
},
]}
onDownload={(slot) => {
const info = active.app[slot.key as keyof AppInfo];
if (info?.DownlodeURL) handleDownload(info);
}}
/>
</div>
{/* Release Notes / About This Version */}
@@ -324,18 +362,18 @@ export default function JoinBetaPage() {
<div className="flex items-center gap-2 mb-4">
<FileText className="size-5 text-primary" />
<h2 className="text-xl font-semibold">
{data.releaseNotes ? "版本更新内容" : "关于此版本"}
{active.releaseNotes ? "版本更新内容" : "关于此版本"}
</h2>
</div>
{data.releaseNotes ? (
<ReleaseNotes text={data.releaseNotes} />
) : data.aboutversion ? (
{active.releaseNotes ? (
<ReleaseNotes text={active.releaseNotes} />
) : active.aboutversion ? (
<>
<p className="text-muted-foreground leading-relaxed mb-4">
{data.aboutversion.about}
{active.aboutversion.about}
</p>
<ul className="flex flex-col gap-2">
{Object.entries(data.aboutversion["about-list"]).map(
{Object.entries(active.aboutversion["about-list"]).map(
([key, text]) => (
<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" />