feat: 下载中心对接 GitHub Releases 并适配 latest.yml

- 新增 /download 下载页:正式版/预览版双渠道、latest.yml/latest-beta.yml 数据源、镜像下载源切换、SHA512 校验展示
- 新增 /api/download-version:GitHub API 自动识别 + 镜像兜底 + yml 解析 + 旧源兜底
- /launcher/join-beta 改为自动识别 GitHub 预览版,支持镜像下载源切换
- 新增 markdown-lite 轻量渲染器、lib/download-sources 与 lib/github-releases 共享模块
- 头部导航新增下载中心入口
This commit is contained in:
2026-08-31 12:01:41 +08:00
parent 5193b1c156
commit dcae468055
8 changed files with 1198 additions and 30 deletions
+186 -8
View File
@@ -1,12 +1,190 @@
import { NextResponse } from "next/server";
export async function GET() {
const res = await fetch(
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"
);
if (!res.ok) {
return NextResponse.json({ error: `HTTP ${res.status}` }, { status: res.status });
/**
* 预览版(Beta)发布信息接口
*
* 数据源优先级:
* 1. GitHub Releases API(直连)—— 主源,自动识别最新预览版
* 2. GitHub Releases APIgh-proxy.com 镜像)—— 国内可达
* 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本)
*
* 发布方案与仓库信息见《Electron Windows自动更新方案规划》:
* 仓库:dream-pep/koring-launcher
* 预览版 tagv{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease
* 正式版 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 =
"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> = {
"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_APPS = {
windows: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
macos: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
linux: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
};
// 简单内存缓存(TTL 15 分钟),避免频繁打 GitHub API / 限流代理
const CACHE_TTL = 15 * 60 * 1000;
const cache = new Map<string, { time: number; value: unknown }>();
async function fetchJson(url: string, headers?: Record<string, string>) {
const res = await fetch(url, {
headers,
cache: "no-store",
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;
try {
return await res.json();
} catch {
return null;
}
const data = await res.json();
return NextResponse.json(data);
}
/** 依次尝试多个数据源,返回第一个成功的结果 */
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[]) {
const assets = release?.assets || [];
for (const p of patterns) {
const hit = assets.find((a: any) => p.test(String(a?.name || "")));
if (hit) return hit;
}
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) => ({
isNoVersion: hit ? "false" : "true",
licence: legacyApp?.[key]?.licence ?? EMPTY_LICENCE,
DownlodeURL: hit?.browser_download_url || "",
});
return {
windows: mk(win, "windows"),
macos: mk(mac, "macos"),
linux: mk(lin, "linux"),
};
}
function buildGithubPayload(release: any, legacy: any) {
const version = String(release.tag_name || "").replace(/^v/i, "");
return {
source: "github",
version,
builddate: release.published_at || release.created_at || "",
tag: release.tag_name,
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${release.tag_name}`,
releaseNotes: release.body || "",
app: buildPlatforms(release, legacy?.app),
};
}
function buildLegacyPayload(legacy: any) {
return {
source: "legacy",
version: String(legacy.version || ""),
builddate: String(legacy.builddate || ""),
app: legacy.app ?? EMPTY_APPS,
aboutversion: legacy.aboutversion,
};
}
export async function GET() {
const now = Date.now();
const cached = cache.get("beta");
if (cached && now - cached.time < CACHE_TTL) {
return NextResponse.json(cached.value);
}
// 1) 主源:GitHub Releases API(直连 -> 镜像)
const releases = (await trySources([
{ url: GITHUB_API, headers: GH_HEADERS },
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
])) as any[] | null;
// 2) 兜底源:旧静态 JSON(也用于补充 Beta 协议 licence
const legacy = await trySources([{ url: LEGACY_URL }]);
let payload: unknown;
if (Array.isArray(releases) && releases.length > 0) {
const beta = pickBetaRelease(releases);
if (beta) {
payload = buildGithubPayload(beta, legacy);
} else {
// GitHub 可达但没有任何预览版
payload = {
source: "github",
noRelease: true,
version: "",
builddate: "",
app: EMPTY_APPS,
};
}
} else if (legacy) {
payload = buildLegacyPayload(legacy);
} else {
return NextResponse.json(
{ error: "无法获取版本信息,请稍后重试" },
{ status: 502 }
);
}
cache.set("beta", { time: now, value: payload });
return NextResponse.json(payload);
}
+144
View File
@@ -0,0 +1,144 @@
import { NextResponse } from "next/server";
import {
fetchReleases,
pickBetaRelease,
pickStableRelease,
pickAsset,
buildDownloadUrl,
fetchYmlForRelease,
RELEASES_PAGE,
} from "@/lib/github-releases";
/**
* 下载页版本信息接口
* 同时返回 正式版(latest.yml)与 预览版(latest-beta.yml / latest.yml)两个渠道,
* 版本 / 安装包文件名 / 大小 / SHA512 / 发布时间 均取自 electron-builder 发布的 yml
* 与客户端自动更新机制(electron-updater)保持一致。
*/
const CACHE_TTL = 15 * 60 * 1000;
const cache = new Map<string, { time: number; value: unknown }>();
const LEGACY_URL =
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json";
async function buildChannelAsync(
release: any,
ymlNames: string[],
fallbackExePatterns: RegExp[]
) {
if (!release) return null;
const tag = String(release.tag_name || "");
const version = tag.replace(/^v/i, "");
// 主源:latest.yml / latest-beta.ymlelectron-updater 权威数据)
const yml = await fetchYmlForRelease(release, ymlNames);
const ymlFile = yml?.files?.[0];
const fileName = ymlFile?.url || yml?.path || "";
const windowsFile = fileName
? { name: fileName }
: pickAsset(release, fallbackExePatterns);
const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]);
const lin = pickAsset(release, [
/\.appimage$/i,
/\.deb$/i,
/\.rpm$/i,
/\.tar\.gz$/i,
]);
return {
version,
tag,
releaseDate: yml?.releaseDate || release.published_at || release.created_at || "",
releaseNotes: release.body || "",
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`,
windows: windowsFile
? {
name: windowsFile.name,
url: buildDownloadUrl(tag, windowsFile.name),
size: ymlFile?.size ?? undefined,
sha512: yml?.sha512 || ymlFile?.sha512 || "",
}
: null,
macos: mac
? { name: mac.name, url: buildDownloadUrl(tag, mac.name) }
: null,
linux: lin
? { name: lin.name, url: buildDownloadUrl(tag, lin.name) }
: null,
};
}
function buildLegacyPayload(legacy: any) {
const win = legacy?.app?.windows;
const preview: any = {
version: String(legacy?.version || ""),
tag: "",
releaseDate: "",
releaseNotes: legacy?.aboutversion?.about || "",
htmlUrl: RELEASES_PAGE,
windows: win?.DownlodeURL
? {
name: String(win.DownlodeURL).split("/").pop() || "",
url: win.DownlodeURL,
size: undefined,
sha512: "",
}
: null,
macos: null,
linux: null,
};
return { source: "legacy", stable: null, preview };
}
export async function GET() {
const now = Date.now();
const cached = cache.get("download");
if (cached && now - cached.time < CACHE_TTL) {
return NextResponse.json(cached.value);
}
let payload: unknown;
const releases = await fetchReleases();
if (Array.isArray(releases) && releases.length > 0) {
const stableRelease = pickStableRelease(releases);
const betaRelease = pickBetaRelease(releases);
const [stable, preview] = await Promise.all([
buildChannelAsync(stableRelease, ["latest.yml"], [/setup\.exe$/i, /\.exe$/i]),
buildChannelAsync(
betaRelease,
["latest-beta.yml", "latest.yml"],
[/setup\.exe$/i, /\.exe$/i]
),
]);
payload = { source: "github", stable, preview };
} else {
// GitHub 不可达 -> 旧静态源兜底(仅预览渠道)
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;
}
if (legacy) {
payload = buildLegacyPayload(legacy);
} else {
return NextResponse.json(
{ error: "无法获取版本信息,请稍后重试" },
{ status: 502 }
);
}
}
cache.set("download", { time: now, value: payload });
return NextResponse.json(payload);
}
+330
View File
@@ -0,0 +1,330 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Download,
Package,
Calendar,
HardDrive,
ShieldCheck,
Copy,
Check,
Loader2,
AlertCircle,
ArrowUpRight,
GitBranch,
FileText,
Inbox,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownLite } from "@/components/markdown-lite";
import {
DOWNLOAD_SOURCES,
resolveDownloadUrl,
type DownloadSourceKey,
} from "@/lib/download-sources";
interface ChannelPlatform {
name: string;
url: string;
size?: number;
sha512?: string;
}
interface ChannelData {
version: string;
tag?: string;
releaseDate?: string;
releaseNotes?: string;
htmlUrl?: string;
windows?: ChannelPlatform | null;
macos?: ChannelPlatform | null;
linux?: ChannelPlatform | null;
}
interface DownloadData {
source?: "github" | "legacy";
stable: ChannelData | null;
preview: ChannelData | null;
}
const CHANNEL_META = [
{ key: "stable" as const, label: "正式版", desc: "稳定渠道,与自动更新的慢走模式一致" },
{ key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" },
];
export default function DownloadPage() {
const [data, setData] = useState<DownloadData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [channel, setChannel] = useState<"stable" | "preview">("stable");
const [downloadSource, setDownloadSource] =
useState<DownloadSourceKey>("github");
const [copied, setCopied] = useState(false);
useEffect(() => {
fetch("/api/download-version")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json: DownloadData) => {
setData(json);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
// 当前渠道:选中的不存在时自动回退到可用渠道
const activeKey =
data && data[channel] ? channel : data?.stable ? "stable" : "preview";
const active = data ? data[activeKey] : null;
const win = active?.windows || null;
const isGithub = data?.source === "github";
const formatSize = (bytes?: number) =>
bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : "未知";
const formatDate = (d?: string) =>
d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
const handleDownload = () => {
if (!win?.url) return;
window.open(resolveDownloadUrl(downloadSource, win.url), "_blank");
};
const copySha = async () => {
if (!win?.sha512) return;
try {
await navigator.clipboard.writeText(win.sha512);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// 剪贴板不可用时忽略
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">...</p>
</div>
);
}
if (error || !data) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<AlertCircle className="size-8 text-destructive" />
<p className="text-muted-foreground"></p>
<Button variant="outline" onClick={() => window.location.reload()}>
</Button>
</div>
);
}
if (!active) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<Inbox className="size-10 text-muted-foreground" />
<div>
<p className="font-semibold text-lg"></p>
<p className="text-sm text-muted-foreground max-w-sm mt-1">
</p>
</div>
<a
href="https://github.com/dream-pep/koring-launcher/releases"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
GitHub Releases
<ArrowUpRight className="size-4" />
</Button>
</a>
</div>
);
}
const meta = CHANNEL_META.find((c) => c.key === activeKey)!;
return (
<div className="flex flex-col w-full h-full gap-10 pb-24">
{/* Header */}
<div className="flex flex-col gap-2">
<Badge variant="outline" className="w-fit">
</Badge>
<h1 className="text-3xl md:text-5xl font-bold tracking-tight">
Koring Launcher
</h1>
<p className="text-muted-foreground text-base md:text-lg max-w-xl">
Electron Minecraft
latest.yml使
</p>
{isGithub && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<GitBranch className="size-3" />
GitHub Releases
</span>
)}
</div>
{/* 渠道切换 */}
<div className="flex flex-wrap items-center gap-2">
{CHANNEL_META.map((c) => {
const d = c.key === "stable" ? data.stable : data.preview;
return (
<button
key={c.key}
type="button"
disabled={!d}
onClick={() => setChannel(c.key)}
className={cn(
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
activeKey === c.key
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
!d && "opacity-40 cursor-not-allowed"
)}
>
{c.label}
{d && <span className="ml-1.5 text-xs opacity-70">· {d.version}</span>}
</button>
);
})}
</div>
{/* 渠道信息卡 */}
<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="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10">
<Package className="size-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
{active.version}
</h2>
<Badge variant="outline">{meta.label}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{meta.desc}
</p>
</div>
</div>
{active.htmlUrl && (
<a
href={active.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-primary underline underline-offset-3 hover:text-primary/80"
>
GitHub
<ArrowUpRight className="size-3.5" />
</a>
)}
</div>
{/* 元信息 */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
<Calendar className="size-5 text-primary shrink-0" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-semibold text-sm">
{formatDate(active.releaseDate)}
</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
<HardDrive className="size-5 text-primary shrink-0" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-semibold text-sm">{formatSize(win?.size)}</p>
</div>
</div>
<button
type="button"
onClick={copySha}
title="点击复制 SHA512"
className="flex items-center gap-3 rounded-xl border border-border/50 p-3 text-left transition-colors hover:border-primary/50"
>
<ShieldCheck className="size-5 text-primary shrink-0" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">
SHA512{copied ? "(已复制)" : "(点击复制)"}
</p>
<p className="font-mono text-xs text-muted-foreground truncate">
{win?.sha512 || "未知"}
</p>
</div>
</button>
</div>
{/* 下载区 */}
{win ? (
<div className="mt-6 flex flex-col gap-4">
<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>
{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>
<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>
) : (
<div className="mt-6 rounded-xl border border-border/30 p-4 text-center text-sm text-muted-foreground">
</div>
)}
</div>
{/* 更新内容 */}
{active.releaseNotes && (
<div className="border-t border-border/50 p-6 md:p-8">
<div className="flex items-center gap-2 mb-3">
<FileText className="size-5 text-primary" />
<h3 className="text-lg font-semibold"></h3>
</div>
<MarkdownLite text={active.releaseNotes} />
</div>
)}
</div>
</div>
);
}
+138 -22
View File
@@ -19,11 +19,20 @@ import {
AlertCircle,
Package,
Hash,
Calendar,
GitBranch,
PackageOpen,
ArrowUpRight,
FileText,
Download,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownLite } from "@/components/markdown-lite";
import {
DOWNLOAD_SOURCES,
resolveDownloadUrl,
type DownloadSourceKey,
} from "@/lib/download-sources";
interface License {
isNeedAgreat: string;
@@ -46,12 +55,19 @@ interface AboutVersion {
interface BetaData {
version: string;
builddate: string;
/** 数据来源:github = GitHub Releases 自动识别;legacy = 旧静态源兜底 */
source?: "github" | "legacy";
tag?: string;
htmlUrl?: string;
releaseNotes?: string;
/** GitHub 可达但没有预览版时的标记 */
noRelease?: boolean;
app: {
windows: PlatformInfo;
macos: PlatformInfo;
linux: PlatformInfo;
};
aboutversion: AboutVersion;
aboutversion?: AboutVersion;
}
const platforms = [
@@ -66,6 +82,7 @@ export default function JoinBetaPage() {
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
useEffect(() => {
fetch("/api/beta-version")
@@ -88,13 +105,19 @@ export default function JoinBetaPage() {
setActivePlatform(platform);
setDialogOpen(true);
} else {
window.open(platform.DownlodeURL, "_blank");
window.open(
resolveDownloadUrl(downloadSource, platform.DownlodeURL),
"_blank"
);
}
};
const confirmDownload = () => {
if (activePlatform?.DownlodeURL) {
window.open(activePlatform.DownlodeURL, "_blank");
window.open(
resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL),
"_blank"
);
}
setDialogOpen(false);
setActivePlatform(null);
@@ -121,6 +144,30 @@ export default function JoinBetaPage() {
);
}
if (data.noRelease) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<PackageOpen className="size-10 text-muted-foreground" />
<div>
<p className="font-semibold text-lg"></p>
<p className="text-sm text-muted-foreground max-w-sm mt-1">
GitHub Releases Beta
</p>
</div>
<a
href="https://github.com/dream-pep/koring-launcher/releases"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
GitHub Releases
<ArrowUpRight className="size-4" />
</Button>
</a>
</div>
);
}
return (
<div className="flex flex-col w-full h-full gap-10 pb-24">
{/* Header */}
@@ -134,6 +181,32 @@ export default function JoinBetaPage() {
<p className="text-muted-foreground text-base md:text-lg max-w-xl">
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" ? (
<>
<span className="flex items-center gap-1">
<GitBranch className="size-3" />
GitHub Releases
</span>
{data.htmlUrl && (
<a
href={data.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary underline underline-offset-3 hover:text-primary/80"
>
GitHub
<ArrowUpRight className="size-3" />
</a>
)}
</>
) : (
<span className="flex items-center gap-1">
<GitBranch className="size-3" />
</span>
)}
</div>
</div>
{/* Version Info Card */}
@@ -149,18 +222,53 @@ export default function JoinBetaPage() {
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary/10">
<Hash className="size-5 text-primary" />
{data.source === "github" ? (
<Calendar className="size-5 text-primary" />
) : (
<Hash className="size-5 text-primary" />
)}
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-semibold text-lg">{data.builddate}</p>
<p className="text-xs text-muted-foreground">
{data.source === "github" ? "发布时间" : "编译号"}
</p>
<p className="font-semibold text-lg">
{data.source === "github"
? data.builddate
? new Date(data.builddate).toLocaleDateString("zh-CN")
: "未知"
: data.builddate}
</p>
</div>
</div>
</div>
{/* Platform Cards */}
<div>
<h2 className="text-xl font-semibold mb-4"></h2>
<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];
@@ -211,25 +319,33 @@ export default function JoinBetaPage() {
</div>
</div>
{/* 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="flex items-center gap-2 mb-4">
<FileText className="size-5 text-primary" />
<h2 className="text-xl font-semibold"></h2>
<h2 className="text-xl font-semibold">
{data.releaseNotes ? "版本更新内容" : "关于此版本"}
</h2>
</div>
<p className="text-muted-foreground leading-relaxed mb-4">
{data.aboutversion.about}
</p>
<ul className="flex flex-col gap-2">
{Object.entries(data.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" />
<span className="text-muted-foreground">{text}</span>
</li>
)
)}
</ul>
{data.releaseNotes ? (
<MarkdownLite text={data.releaseNotes} />
) : data.aboutversion ? (
<>
<p className="text-muted-foreground leading-relaxed mb-4">
{data.aboutversion.about}
</p>
<ul className="flex flex-col gap-2">
{Object.entries(data.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" />
<span className="text-muted-foreground">{text}</span>
</li>
)
)}
</ul>
</>
) : null}
</div>
{/* License Dialog */}