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 { 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 API(gh-proxy.com 镜像)—— 国内可达
|
* 2. 旧静态源 version.json —— 兜底(同时提供 Beta 协议 licence 文本)
|
||||||
* 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本)
|
|
||||||
*
|
*
|
||||||
* 发布方案与仓库信息见《Electron Windows自动更新方案规划》:
|
* 返回:
|
||||||
* 仓库:dream-pep/koring-launcher
|
* 顶层字段 = 最新预览版(licence 从旧静态源补充,下载需同意协议)
|
||||||
* 预览版 tag:v{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease
|
* versions = 历史预览版列表(新 -> 旧,按资产匹配平台;无 licence 数据,直接下载)
|
||||||
* 正式版 tag:v{base}-{RUN_NUMBER}(如 v1.2.5-14),非 prerelease
|
|
||||||
* 安装包命名:koring-launcher-{version}-setup.exe(electron-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选出最新预览版 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 || "")))
|
|
||||||
);
|
);
|
||||||
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) {
|
||||||
|
|||||||
@@ -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.yml(electron-updater 权威数据)
|
// 主源:latest.yml / latest-beta.yml(electron-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 || "",
|
|
||||||
}
|
/** 历史版本条目(资产匹配,不含 yml) */
|
||||||
: null,
|
function buildHistoryEntry(release: any) {
|
||||||
macos: mac
|
const tag = String(release.tag_name || "");
|
||||||
? { name: mac.name, url: buildDownloadUrl(tag, mac.name) }
|
return {
|
||||||
: null,
|
version: tag.replace(/^v/i, ""),
|
||||||
linux: lin
|
tag,
|
||||||
? { name: lin.name, url: buildDownloadUrl(tag, lin.name) }
|
channel: isBetaRelease(release) ? "preview" : "stable",
|
||||||
: null,
|
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
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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",
|
"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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最新预览版 release(prerelease 标记或 -beta.N tag) */
|
/** 最新预览版 release(prerelease 标记或预览 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user