mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
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:
+82
-101
@@ -1,35 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
fetchReleases,
|
||||
pickBetaRelease,
|
||||
pickAsset,
|
||||
buildDownloadUrl,
|
||||
isBetaRelease,
|
||||
RELEASES_PAGE,
|
||||
} from "@/lib/github-releases";
|
||||
|
||||
/**
|
||||
* 预览版(Beta)发布信息接口
|
||||
*
|
||||
* 数据源优先级:
|
||||
* 1. GitHub Releases API(直连)—— 主源,自动识别最新预览版
|
||||
* 2. GitHub Releases API(gh-proxy.com 镜像)—— 国内可达
|
||||
* 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本)
|
||||
* 1. GitHub Releases API(直连 -> 镜像)—— 主源,自动识别最新预览版
|
||||
* 2. 旧静态源 version.json —— 兜底(同时提供 Beta 协议 licence 文本)
|
||||
*
|
||||
* 发布方案与仓库信息见《Electron Windows自动更新方案规划》:
|
||||
* 仓库:dream-pep/koring-launcher
|
||||
* 预览版 tag:v{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease
|
||||
* 正式版 tag:v{base}-{RUN_NUMBER}(如 v1.2.5-14),非 prerelease
|
||||
* 安装包命名:koring-launcher-{version}-setup.exe(electron-builder artifactName)
|
||||
* 返回:
|
||||
* 顶层字段 = 最新预览版(licence 从旧静态源补充,下载需同意协议)
|
||||
* versions = 历史预览版列表(新 -> 旧,按资产匹配平台;无 licence 数据,直接下载)
|
||||
*/
|
||||
|
||||
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 HISTORY_LIMIT = 10;
|
||||
|
||||
const EMPTY_LICENCE = { isNeedAgreat: "false", title: "", text: "", liceURL: "" };
|
||||
|
||||
@@ -43,94 +37,62 @@ const EMPTY_APPS = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 依次尝试多个数据源,返回第一个成功的结果 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 选出最新预览版 release(prerelease 标记或 -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 || "")))
|
||||
function sortDesc(releases: any[]) {
|
||||
return [...(releases || [])].sort((a, b) =>
|
||||
String(b.published_at || b.created_at || "").localeCompare(
|
||||
String(a.published_at || a.created_at || "")
|
||||
)
|
||||
);
|
||||
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,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 平台信息(DownlodeURL 为下载直链)
|
||||
* licenceFor:给某平台提供 licence(最新版用旧源协议文本,历史版用空 = 无需同意)
|
||||
*/
|
||||
function buildPlatforms(release: any, licenceFor: (key: string) => any) {
|
||||
const tag = String(release.tag_name || "");
|
||||
const mk = (hit: any, key: string) => ({
|
||||
isNoVersion: hit ? "false" : "true",
|
||||
licence: legacyApp?.[key]?.licence ?? EMPTY_LICENCE,
|
||||
DownlodeURL: hit?.browser_download_url || "",
|
||||
licence: licenceFor(key),
|
||||
DownlodeURL: hit ? buildDownloadUrl(tag, hit.name) : "",
|
||||
});
|
||||
|
||||
return {
|
||||
windows: mk(win, "windows"),
|
||||
macos: mk(mac, "macos"),
|
||||
linux: mk(lin, "linux"),
|
||||
windows: mk(pickAsset(release, [/setup\.exe$/i, /\.exe$/i]), "windows"),
|
||||
macos: mk(pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]), "macos"),
|
||||
linux: mk(
|
||||
pickAsset(release, [/\.appimage$/i, /\.deb$/i, /\.rpm$/i, /\.tar\.gz$/i]),
|
||||
"linux"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGithubPayload(release: any, legacy: any) {
|
||||
const version = String(release.tag_name || "").replace(/^v/i, "");
|
||||
function baseEntry(release: any) {
|
||||
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 {
|
||||
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),
|
||||
...baseEntry(beta),
|
||||
app: buildPlatforms(beta, (key) => licenceSrc?.[key]?.licence ?? EMPTY_LICENCE),
|
||||
};
|
||||
}
|
||||
|
||||
/** 历史预览版条目(无协议要求) */
|
||||
function buildHistoryEntry(release: any) {
|
||||
return {
|
||||
...baseEntry(release),
|
||||
app: buildPlatforms(release, () => EMPTY_LICENCE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,20 +114,38 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// 1) 主源:GitHub Releases API(直连 -> 镜像)
|
||||
const releases = (await trySources([
|
||||
{ url: GITHUB_API, headers: GH_HEADERS },
|
||||
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
|
||||
])) as any[] | null;
|
||||
const releases = await fetchReleases();
|
||||
|
||||
// 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;
|
||||
|
||||
if (Array.isArray(releases) && releases.length > 0) {
|
||||
const beta = pickBetaRelease(releases);
|
||||
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 {
|
||||
// GitHub 可达但没有任何预览版
|
||||
payload = {
|
||||
@@ -174,6 +154,7 @@ export async function GET() {
|
||||
version: "",
|
||||
builddate: "",
|
||||
app: EMPTY_APPS,
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
} else if (legacy) {
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
pickAsset,
|
||||
buildDownloadUrl,
|
||||
fetchYmlForRelease,
|
||||
isBetaRelease,
|
||||
RELEASES_PAGE,
|
||||
} from "@/lib/github-releases";
|
||||
|
||||
/**
|
||||
* 下载页版本信息接口
|
||||
* 同时返回 正式版(latest.yml)与 预览版(latest-beta.yml / latest.yml)两个渠道,
|
||||
* 版本 / 安装包文件名 / 大小 / SHA512 / 发布时间 均取自 electron-builder 发布的 yml,
|
||||
* 与客户端自动更新机制(electron-updater)保持一致。
|
||||
* 返回:
|
||||
* stable / preview —— 正式版与预览版最新一条(含 latest.yml 数据:文件名/大小/SHA512/发布时间,
|
||||
* 与 electron-updater 自动更新机制一致)
|
||||
* versions —— 历史版本列表(新 -> 旧,按资产匹配平台,无 yml 数据)
|
||||
*/
|
||||
|
||||
const CACHE_TTL = 15 * 60 * 1000;
|
||||
@@ -22,11 +24,34 @@ 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[]
|
||||
) {
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
const EXE_PATTERNS = [/setup\.exe$/i, /\.exe$/i];
|
||||
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;
|
||||
const tag = String(release.tag_name || "");
|
||||
const version = tag.replace(/^v/i, "");
|
||||
@@ -34,40 +59,44 @@ async function buildChannelAsync(
|
||||
// 主源:latest.yml / latest-beta.yml(electron-updater 权威数据)
|
||||
const yml = await fetchYmlForRelease(release, ymlNames);
|
||||
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
|
||||
? { 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,
|
||||
]);
|
||||
const windows = windowsName
|
||||
? {
|
||||
name: windowsName,
|
||||
url: buildDownloadUrl(tag, windowsName),
|
||||
size: ymlFile?.size ?? undefined,
|
||||
sha512: yml?.sha512 || ymlFile?.sha512 || "",
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
version,
|
||||
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 || "",
|
||||
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,
|
||||
windows,
|
||||
macos: plat.macos,
|
||||
linux: plat.linux,
|
||||
};
|
||||
}
|
||||
|
||||
/** 历史版本条目(资产匹配,不含 yml) */
|
||||
function buildHistoryEntry(release: any) {
|
||||
const tag = String(release.tag_name || "");
|
||||
return {
|
||||
version: tag.replace(/^v/i, ""),
|
||||
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 = {
|
||||
version: String(legacy?.version || ""),
|
||||
tag: "",
|
||||
channel: "preview",
|
||||
releaseDate: "",
|
||||
releaseNotes: legacy?.aboutversion?.about || "",
|
||||
htmlUrl: RELEASES_PAGE,
|
||||
@@ -90,7 +120,7 @@ function buildLegacyPayload(legacy: any) {
|
||||
macos: null,
|
||||
linux: null,
|
||||
};
|
||||
return { source: "legacy", stable: null, preview };
|
||||
return { source: "legacy", stable: null, preview, versions: [preview] };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -108,15 +138,16 @@ export async function GET() {
|
||||
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]
|
||||
),
|
||||
buildChannelAsync(stableRelease, ["latest.yml"]),
|
||||
buildChannelAsync(betaRelease, ["latest-beta.yml", "latest.yml"]),
|
||||
]);
|
||||
|
||||
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 {
|
||||
// GitHub 不可达 -> 旧静态源兜底(仅预览渠道)
|
||||
let legacy: any = null;
|
||||
|
||||
+172
-87
@@ -1,16 +1,17 @@
|
||||
"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 {
|
||||
Download,
|
||||
Package,
|
||||
Calendar,
|
||||
HardDrive,
|
||||
ShieldCheck,
|
||||
Copy,
|
||||
Check,
|
||||
History,
|
||||
Monitor,
|
||||
Apple,
|
||||
Terminal,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} 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,
|
||||
@@ -36,6 +38,7 @@ interface ChannelPlatform {
|
||||
interface ChannelData {
|
||||
version: string;
|
||||
tag?: string;
|
||||
channel?: "stable" | "preview";
|
||||
releaseDate?: string;
|
||||
releaseNotes?: string;
|
||||
htmlUrl?: string;
|
||||
@@ -48,6 +51,7 @@ interface DownloadData {
|
||||
source?: "github" | "legacy";
|
||||
stable: ChannelData | null;
|
||||
preview: ChannelData | null;
|
||||
versions: ChannelData[];
|
||||
}
|
||||
|
||||
const CHANNEL_META = [
|
||||
@@ -55,11 +59,15 @@ const CHANNEL_META = [
|
||||
{ key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" },
|
||||
];
|
||||
|
||||
type Selection =
|
||||
| { kind: "latest"; channel: "stable" | "preview" }
|
||||
| { kind: "version"; version: string };
|
||||
|
||||
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 [selection, setSelection] = useState<Selection | null>(null);
|
||||
const [downloadSource, setDownloadSource] =
|
||||
useState<DownloadSourceKey>("github");
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -80,23 +88,43 @@ export default function DownloadPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 当前渠道:选中的不存在时自动回退到可用渠道
|
||||
const activeKey =
|
||||
data && data[channel] ? channel : data?.stable ? "stable" : "preview";
|
||||
const active = data ? data[activeKey] : null;
|
||||
const win = active?.windows || null;
|
||||
// 汇总全部版本(最新渠道条目带 yml 数据,覆盖历史同名条目)
|
||||
const allVersions = useMemo<ChannelData[]>(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, ChannelData>();
|
||||
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 win = active?.windows || null;
|
||||
const hasAnyDownload = !!(
|
||||
active && (active.windows?.url || active.macos?.url || active.linux?.url)
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -108,6 +136,11 @@ export default function DownloadPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlatformDownload = (url?: string | null) => {
|
||||
if (!url) return;
|
||||
window.open(resolveDownloadUrl(downloadSource, url), "_blank");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex flex-col w-full h-full gap-10 pb-24">
|
||||
@@ -177,32 +217,60 @@ export default function DownloadPage() {
|
||||
)}
|
||||
</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"
|
||||
)}
|
||||
{/* 渠道切换 + 历史版本 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{CHANNEL_META.map((c) => {
|
||||
const d = c.key === "stable" ? data.stable : data.preview;
|
||||
const isActive =
|
||||
effectiveSelection.kind === "latest" &&
|
||||
effectiveSelection.channel === c.key;
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
disabled={!d}
|
||||
onClick={() => setSelection({ kind: "latest", channel: c.key })}
|
||||
className={cn(
|
||||
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "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>
|
||||
|
||||
{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"
|
||||
>
|
||||
{c.label}
|
||||
{d && <span className="ml-1.5 text-xs opacity-70">· {d.version}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{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="p-6 md:p-8">
|
||||
<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">
|
||||
{active.version}
|
||||
</h2>
|
||||
<Badge variant="outline">{meta.label}</Badge>
|
||||
<Badge variant="outline">{activeChannelLabel}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{meta.desc}
|
||||
{activeDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,75 +317,92 @@ export default function DownloadPage() {
|
||||
<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="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"
|
||||
disabled={!win?.sha512}
|
||||
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" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SHA512{copied ? "(已复制)" : "(点击复制)"}
|
||||
SHA512{win?.sha512 && (copied ? "(已复制)" : "(点击复制)")}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||
{win?.sha512 || "未知"}
|
||||
{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">
|
||||
此渠道暂未提供安装包
|
||||
{/* 下载源切换 */}
|
||||
{isGithub && hasAnyDownload && (
|
||||
<div className="mt-6 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 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>
|
||||
|
||||
{/* 更新内容 */}
|
||||
{/* 此版本的变更 */}
|
||||
{active.releaseNotes && (
|
||||
<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" />
|
||||
<h3 className="text-lg font-semibold">此版本的变更</h3>
|
||||
</div>
|
||||
|
||||
+139
-101
@@ -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" />
|
||||
|
||||
@@ -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
@@ -20,8 +20,28 @@ const GH_HEADERS: Record<string, string> = {
|
||||
"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 {
|
||||
ok: boolean;
|
||||
@@ -80,14 +100,9 @@ function byPublishedDesc(a: any, b: any) {
|
||||
return tb.localeCompare(ta);
|
||||
}
|
||||
|
||||
/** 最新预览版 release(prerelease 标记或 -beta.N tag) */
|
||||
/** 最新预览版 release(prerelease 标记或预览 tag) */
|
||||
export function pickBetaRelease(releases: any[]) {
|
||||
const betas = (releases || []).filter(
|
||||
(r) =>
|
||||
r &&
|
||||
!r.draft &&
|
||||
(r.prerelease === true || BETA_TAG.test(String(r.tag_name || "")))
|
||||
);
|
||||
const betas = (releases || []).filter((r) => isBetaRelease(r));
|
||||
betas.sort(byPublishedDesc);
|
||||
return betas[0] || null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user