mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
feat: 下载中心对接 GitHub Releases 并适配 latest.yml
- 新增 /download 下载页:正式版/预览版双渠道、latest.yml/latest-beta.yml 数据源、镜像下载源切换、SHA512 校验展示 - 新增 /api/download-version:GitHub API 自动识别 + 镜像兜底 + yml 解析 + 旧源兜底 - /launcher/join-beta 改为自动识别 GitHub 预览版,支持镜像下载源切换 - 新增 markdown-lite 轻量渲染器、lib/download-sources 与 lib/github-releases 共享模块 - 头部导航新增下载中心入口
This commit is contained in:
@@ -1,12 +1,190 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
/**
|
||||||
const res = await fetch(
|
* 预览版(Beta)发布信息接口
|
||||||
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"
|
*
|
||||||
);
|
* 数据源优先级:
|
||||||
if (!res.ok) {
|
* 1. GitHub Releases API(直连)—— 主源,自动识别最新预览版
|
||||||
return NextResponse.json({ error: `HTTP ${res.status}` }, { status: res.status });
|
* 2. GitHub Releases API(gh-proxy.com 镜像)—— 国内可达
|
||||||
|
* 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本)
|
||||||
|
*
|
||||||
|
* 发布方案与仓库信息见《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)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO = "dream-pep/koring-launcher";
|
||||||
|
const GITHUB_API = `https://api.github.com/repos/${REPO}/releases?per_page=100`;
|
||||||
|
const GITHUB_API_PROXY = `https://gh-proxy.com/https://api.github.com/repos/${REPO}/releases?per_page=100`;
|
||||||
|
const LEGACY_URL =
|
||||||
|
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json";
|
||||||
|
const RELEASES_PAGE = `https://github.com/${REPO}/releases`;
|
||||||
|
|
||||||
|
const GH_HEADERS: Record<string, string> = {
|
||||||
|
"User-Agent": "koring-space-website",
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 预览版 tag 形态:v1.2.5-beta.16 */
|
||||||
|
const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i;
|
||||||
|
|
||||||
|
const EMPTY_LICENCE = { isNeedAgreat: "false", title: "", text: "", liceURL: "" };
|
||||||
|
|
||||||
|
const EMPTY_APPS = {
|
||||||
|
windows: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
|
||||||
|
macos: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
|
||||||
|
linux: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单内存缓存(TTL 15 分钟),避免频繁打 GitHub API / 限流代理
|
||||||
|
const CACHE_TTL = 15 * 60 * 1000;
|
||||||
|
const cache = new Map<string, { time: number; value: unknown }>();
|
||||||
|
|
||||||
|
async function fetchJson(url: string, headers?: Record<string, string>) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
}
|
||||||
return NextResponse.json(data);
|
|
||||||
|
/** 依次尝试多个数据源,返回第一个成功的结果 */
|
||||||
|
async function trySources(
|
||||||
|
sources: Array<{ url: string; headers?: Record<string, string> }>
|
||||||
|
): Promise<unknown | null> {
|
||||||
|
for (const s of sources) {
|
||||||
|
try {
|
||||||
|
const json = await fetchJson(s.url, s.headers);
|
||||||
|
if (json !== null && json !== undefined) return json;
|
||||||
|
} catch {
|
||||||
|
// 继续尝试下一个源
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选出最新预览版 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[]) {
|
||||||
|
const assets = release?.assets || [];
|
||||||
|
for (const p of patterns) {
|
||||||
|
const hit = assets.find((a: any) => p.test(String(a?.name || "")));
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlatforms(release: any, legacyApp: any) {
|
||||||
|
const win = pickAsset(release, [/setup\.exe$/i, /\.exe$/i]);
|
||||||
|
const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]);
|
||||||
|
const lin = pickAsset(release, [
|
||||||
|
/\.appimage$/i,
|
||||||
|
/\.deb$/i,
|
||||||
|
/\.rpm$/i,
|
||||||
|
/\.tar\.gz$/i,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mk = (hit: any, key: string) => ({
|
||||||
|
isNoVersion: hit ? "false" : "true",
|
||||||
|
licence: legacyApp?.[key]?.licence ?? EMPTY_LICENCE,
|
||||||
|
DownlodeURL: hit?.browser_download_url || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
windows: mk(win, "windows"),
|
||||||
|
macos: mk(mac, "macos"),
|
||||||
|
linux: mk(lin, "linux"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGithubPayload(release: any, legacy: any) {
|
||||||
|
const version = String(release.tag_name || "").replace(/^v/i, "");
|
||||||
|
return {
|
||||||
|
source: "github",
|
||||||
|
version,
|
||||||
|
builddate: release.published_at || release.created_at || "",
|
||||||
|
tag: release.tag_name,
|
||||||
|
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${release.tag_name}`,
|
||||||
|
releaseNotes: release.body || "",
|
||||||
|
app: buildPlatforms(release, legacy?.app),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyPayload(legacy: any) {
|
||||||
|
return {
|
||||||
|
source: "legacy",
|
||||||
|
version: String(legacy.version || ""),
|
||||||
|
builddate: String(legacy.builddate || ""),
|
||||||
|
app: legacy.app ?? EMPTY_APPS,
|
||||||
|
aboutversion: legacy.aboutversion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = cache.get("beta");
|
||||||
|
if (cached && now - cached.time < CACHE_TTL) {
|
||||||
|
return NextResponse.json(cached.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 主源:GitHub Releases API(直连 -> 镜像)
|
||||||
|
const releases = (await trySources([
|
||||||
|
{ url: GITHUB_API, headers: GH_HEADERS },
|
||||||
|
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
|
||||||
|
])) as any[] | null;
|
||||||
|
|
||||||
|
// 2) 兜底源:旧静态 JSON(也用于补充 Beta 协议 licence)
|
||||||
|
const legacy = await trySources([{ url: LEGACY_URL }]);
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
|
||||||
|
if (Array.isArray(releases) && releases.length > 0) {
|
||||||
|
const beta = pickBetaRelease(releases);
|
||||||
|
if (beta) {
|
||||||
|
payload = buildGithubPayload(beta, legacy);
|
||||||
|
} else {
|
||||||
|
// GitHub 可达但没有任何预览版
|
||||||
|
payload = {
|
||||||
|
source: "github",
|
||||||
|
noRelease: true,
|
||||||
|
version: "",
|
||||||
|
builddate: "",
|
||||||
|
app: EMPTY_APPS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (legacy) {
|
||||||
|
payload = buildLegacyPayload(legacy);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无法获取版本信息,请稍后重试" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set("beta", { time: now, value: payload });
|
||||||
|
return NextResponse.json(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchReleases,
|
||||||
|
pickBetaRelease,
|
||||||
|
pickStableRelease,
|
||||||
|
pickAsset,
|
||||||
|
buildDownloadUrl,
|
||||||
|
fetchYmlForRelease,
|
||||||
|
RELEASES_PAGE,
|
||||||
|
} from "@/lib/github-releases";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载页版本信息接口
|
||||||
|
* 同时返回 正式版(latest.yml)与 预览版(latest-beta.yml / latest.yml)两个渠道,
|
||||||
|
* 版本 / 安装包文件名 / 大小 / SHA512 / 发布时间 均取自 electron-builder 发布的 yml,
|
||||||
|
* 与客户端自动更新机制(electron-updater)保持一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_TTL = 15 * 60 * 1000;
|
||||||
|
const cache = new Map<string, { time: number; value: unknown }>();
|
||||||
|
|
||||||
|
const LEGACY_URL =
|
||||||
|
"https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json";
|
||||||
|
|
||||||
|
async function buildChannelAsync(
|
||||||
|
release: any,
|
||||||
|
ymlNames: string[],
|
||||||
|
fallbackExePatterns: RegExp[]
|
||||||
|
) {
|
||||||
|
if (!release) return null;
|
||||||
|
const tag = String(release.tag_name || "");
|
||||||
|
const version = tag.replace(/^v/i, "");
|
||||||
|
|
||||||
|
// 主源:latest.yml / latest-beta.yml(electron-updater 权威数据)
|
||||||
|
const yml = await fetchYmlForRelease(release, ymlNames);
|
||||||
|
const ymlFile = yml?.files?.[0];
|
||||||
|
const fileName = ymlFile?.url || yml?.path || "";
|
||||||
|
|
||||||
|
const windowsFile = fileName
|
||||||
|
? { name: fileName }
|
||||||
|
: pickAsset(release, fallbackExePatterns);
|
||||||
|
|
||||||
|
const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]);
|
||||||
|
const lin = pickAsset(release, [
|
||||||
|
/\.appimage$/i,
|
||||||
|
/\.deb$/i,
|
||||||
|
/\.rpm$/i,
|
||||||
|
/\.tar\.gz$/i,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
tag,
|
||||||
|
releaseDate: yml?.releaseDate || release.published_at || release.created_at || "",
|
||||||
|
releaseNotes: release.body || "",
|
||||||
|
htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`,
|
||||||
|
windows: windowsFile
|
||||||
|
? {
|
||||||
|
name: windowsFile.name,
|
||||||
|
url: buildDownloadUrl(tag, windowsFile.name),
|
||||||
|
size: ymlFile?.size ?? undefined,
|
||||||
|
sha512: yml?.sha512 || ymlFile?.sha512 || "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
macos: mac
|
||||||
|
? { name: mac.name, url: buildDownloadUrl(tag, mac.name) }
|
||||||
|
: null,
|
||||||
|
linux: lin
|
||||||
|
? { name: lin.name, url: buildDownloadUrl(tag, lin.name) }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyPayload(legacy: any) {
|
||||||
|
const win = legacy?.app?.windows;
|
||||||
|
const preview: any = {
|
||||||
|
version: String(legacy?.version || ""),
|
||||||
|
tag: "",
|
||||||
|
releaseDate: "",
|
||||||
|
releaseNotes: legacy?.aboutversion?.about || "",
|
||||||
|
htmlUrl: RELEASES_PAGE,
|
||||||
|
windows: win?.DownlodeURL
|
||||||
|
? {
|
||||||
|
name: String(win.DownlodeURL).split("/").pop() || "",
|
||||||
|
url: win.DownlodeURL,
|
||||||
|
size: undefined,
|
||||||
|
sha512: "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
macos: null,
|
||||||
|
linux: null,
|
||||||
|
};
|
||||||
|
return { source: "legacy", stable: null, preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = cache.get("download");
|
||||||
|
if (cached && now - cached.time < CACHE_TTL) {
|
||||||
|
return NextResponse.json(cached.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
|
||||||
|
const releases = await fetchReleases();
|
||||||
|
if (Array.isArray(releases) && releases.length > 0) {
|
||||||
|
const stableRelease = pickStableRelease(releases);
|
||||||
|
const betaRelease = pickBetaRelease(releases);
|
||||||
|
|
||||||
|
const [stable, preview] = await Promise.all([
|
||||||
|
buildChannelAsync(stableRelease, ["latest.yml"], [/setup\.exe$/i, /\.exe$/i]),
|
||||||
|
buildChannelAsync(
|
||||||
|
betaRelease,
|
||||||
|
["latest-beta.yml", "latest.yml"],
|
||||||
|
[/setup\.exe$/i, /\.exe$/i]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
payload = { source: "github", stable, preview };
|
||||||
|
} else {
|
||||||
|
// GitHub 不可达 -> 旧静态源兜底(仅预览渠道)
|
||||||
|
let legacy: any = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(LEGACY_URL, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (res.ok) legacy = await res.json();
|
||||||
|
} catch {
|
||||||
|
legacy = null;
|
||||||
|
}
|
||||||
|
if (legacy) {
|
||||||
|
payload = buildLegacyPayload(legacy);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无法获取版本信息,请稍后重试" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set("download", { time: now, value: payload });
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Package,
|
||||||
|
Calendar,
|
||||||
|
HardDrive,
|
||||||
|
ShieldCheck,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowUpRight,
|
||||||
|
GitBranch,
|
||||||
|
FileText,
|
||||||
|
Inbox,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MarkdownLite } from "@/components/markdown-lite";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_SOURCES,
|
||||||
|
resolveDownloadUrl,
|
||||||
|
type DownloadSourceKey,
|
||||||
|
} from "@/lib/download-sources";
|
||||||
|
|
||||||
|
interface ChannelPlatform {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelData {
|
||||||
|
version: string;
|
||||||
|
tag?: string;
|
||||||
|
releaseDate?: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
htmlUrl?: string;
|
||||||
|
windows?: ChannelPlatform | null;
|
||||||
|
macos?: ChannelPlatform | null;
|
||||||
|
linux?: ChannelPlatform | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadData {
|
||||||
|
source?: "github" | "legacy";
|
||||||
|
stable: ChannelData | null;
|
||||||
|
preview: ChannelData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_META = [
|
||||||
|
{ key: "stable" as const, label: "正式版", desc: "稳定渠道,与自动更新的慢走模式一致" },
|
||||||
|
{ key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DownloadPage() {
|
||||||
|
const [data, setData] = useState<DownloadData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [channel, setChannel] = useState<"stable" | "preview">("stable");
|
||||||
|
const [downloadSource, setDownloadSource] =
|
||||||
|
useState<DownloadSourceKey>("github");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/download-version")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json: DownloadData) => {
|
||||||
|
setData(json);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当前渠道:选中的不存在时自动回退到可用渠道
|
||||||
|
const activeKey =
|
||||||
|
data && data[channel] ? channel : data?.stable ? "stable" : "preview";
|
||||||
|
const active = data ? data[activeKey] : null;
|
||||||
|
const win = active?.windows || null;
|
||||||
|
const isGithub = data?.source === "github";
|
||||||
|
|
||||||
|
const formatSize = (bytes?: number) =>
|
||||||
|
bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : "未知";
|
||||||
|
const formatDate = (d?: string) =>
|
||||||
|
d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!win?.url) return;
|
||||||
|
window.open(resolveDownloadUrl(downloadSource, win.url), "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySha = async () => {
|
||||||
|
if (!win?.sha512) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(win.sha512);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// 剪贴板不可用时忽略
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||||
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">正在获取版本信息...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
<p className="text-muted-foreground">获取版本信息失败</p>
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
|
||||||
|
<Inbox className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-lg">暂无可用版本</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm mt-1">
|
||||||
|
暂时没有可用的发布版本,请稍后再来看看
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/dream-pep/koring-launcher/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline">
|
||||||
|
前往 GitHub Releases
|
||||||
|
<ArrowUpRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = CHANNEL_META.find((c) => c.key === activeKey)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full h-full gap-10 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Badge variant="outline" className="w-fit">
|
||||||
|
下载中心
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-3xl md:text-5xl font-bold tracking-tight">
|
||||||
|
Koring Launcher
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-base md:text-lg max-w-xl">
|
||||||
|
基于 Electron 的 Minecraft 启动器,版本信息与自动更新机制同源(
|
||||||
|
latest.yml),下载安装包即可开始使用
|
||||||
|
</p>
|
||||||
|
{isGithub && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<GitBranch className="size-3" />
|
||||||
|
版本信息由 GitHub Releases 自动识别
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道切换 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{CHANNEL_META.map((c) => {
|
||||||
|
const d = c.key === "stable" ? data.stable : data.preview;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
disabled={!d}
|
||||||
|
onClick={() => setChannel(c.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
|
||||||
|
activeKey === c.key
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground",
|
||||||
|
!d && "opacity-40 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
{d && <span className="ml-1.5 text-xs opacity-70">· {d.version}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道信息卡 */}
|
||||||
|
<div className="rounded-2xl border border-border/50 bg-background/60 backdrop-blur-xl overflow-hidden">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10">
|
||||||
|
<Package className="size-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
|
{active.version}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="outline">{meta.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{meta.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active.htmlUrl && (
|
||||||
|
<a
|
||||||
|
href={active.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-sm text-primary underline underline-offset-3 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
在 GitHub 查看发布页
|
||||||
|
<ArrowUpRight className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
|
||||||
|
<Calendar className="size-5 text-primary shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">发布时间</p>
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{formatDate(active.releaseDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
|
||||||
|
<HardDrive className="size-5 text-primary shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">文件大小</p>
|
||||||
|
<p className="font-semibold text-sm">{formatSize(win?.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copySha}
|
||||||
|
title="点击复制 SHA512"
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border/50 p-3 text-left transition-colors hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="size-5 text-primary shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
SHA512{copied ? "(已复制)" : "(点击复制)"}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{win?.sha512 || "未知"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下载区 */}
|
||||||
|
{win ? (
|
||||||
|
<div className="mt-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground">下载源</span>
|
||||||
|
{DOWNLOAD_SOURCES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDownloadSource(s.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs transition-colors",
|
||||||
|
downloadSource === s.key
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{win.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" onClick={handleDownload}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
下载 Windows 安装包({active.version})
|
||||||
|
</Button>
|
||||||
|
{!active.macos && !active.linux && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
macOS / Linux 版本尚未提供,敬请期待
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-xl border border-border/30 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
此渠道暂未提供安装包
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 更新内容 */}
|
||||||
|
{active.releaseNotes && (
|
||||||
|
<div className="border-t border-border/50 p-6 md:p-8">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="size-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">版本更新内容</h3>
|
||||||
|
</div>
|
||||||
|
<MarkdownLite text={active.releaseNotes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,11 +19,20 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Package,
|
Package,
|
||||||
Hash,
|
Hash,
|
||||||
|
Calendar,
|
||||||
|
GitBranch,
|
||||||
|
PackageOpen,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MarkdownLite } from "@/components/markdown-lite";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_SOURCES,
|
||||||
|
resolveDownloadUrl,
|
||||||
|
type DownloadSourceKey,
|
||||||
|
} from "@/lib/download-sources";
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
isNeedAgreat: string;
|
isNeedAgreat: string;
|
||||||
@@ -46,12 +55,19 @@ interface AboutVersion {
|
|||||||
interface BetaData {
|
interface BetaData {
|
||||||
version: string;
|
version: string;
|
||||||
builddate: string;
|
builddate: string;
|
||||||
|
/** 数据来源:github = GitHub Releases 自动识别;legacy = 旧静态源兜底 */
|
||||||
|
source?: "github" | "legacy";
|
||||||
|
tag?: string;
|
||||||
|
htmlUrl?: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
/** GitHub 可达但没有预览版时的标记 */
|
||||||
|
noRelease?: boolean;
|
||||||
app: {
|
app: {
|
||||||
windows: PlatformInfo;
|
windows: PlatformInfo;
|
||||||
macos: PlatformInfo;
|
macos: PlatformInfo;
|
||||||
linux: PlatformInfo;
|
linux: PlatformInfo;
|
||||||
};
|
};
|
||||||
aboutversion: AboutVersion;
|
aboutversion?: AboutVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
@@ -66,6 +82,7 @@ export default function JoinBetaPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
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");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/beta-version")
|
fetch("/api/beta-version")
|
||||||
@@ -88,13 +105,19 @@ export default function JoinBetaPage() {
|
|||||||
setActivePlatform(platform);
|
setActivePlatform(platform);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
} else {
|
} else {
|
||||||
window.open(platform.DownlodeURL, "_blank");
|
window.open(
|
||||||
|
resolveDownloadUrl(downloadSource, platform.DownlodeURL),
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDownload = () => {
|
const confirmDownload = () => {
|
||||||
if (activePlatform?.DownlodeURL) {
|
if (activePlatform?.DownlodeURL) {
|
||||||
window.open(activePlatform.DownlodeURL, "_blank");
|
window.open(
|
||||||
|
resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL),
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setActivePlatform(null);
|
setActivePlatform(null);
|
||||||
@@ -121,6 +144,30 @@ export default function JoinBetaPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.noRelease) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
|
||||||
|
<PackageOpen className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-lg">暂无预览版本</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm mt-1">
|
||||||
|
GitHub Releases 中暂时没有可用的预览(Beta)版本,请稍后再来看看
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/dream-pep/koring-launcher/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline">
|
||||||
|
前往 GitHub Releases
|
||||||
|
<ArrowUpRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
||||||
@@ -134,6 +181,32 @@ export default function JoinBetaPage() {
|
|||||||
<p className="text-muted-foreground text-base md:text-lg max-w-xl">
|
<p className="text-muted-foreground text-base md:text-lg max-w-xl">
|
||||||
参与 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">
|
||||||
|
{data.source === "github" ? (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="size-3" />
|
||||||
|
版本信息由 GitHub Releases 自动识别
|
||||||
|
</span>
|
||||||
|
{data.htmlUrl && (
|
||||||
|
<a
|
||||||
|
href={data.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-primary underline underline-offset-3 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
在 GitHub 查看发布页
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="size-3" />
|
||||||
|
版本信息来自缓存数据源
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version Info Card */}
|
{/* Version Info Card */}
|
||||||
@@ -149,18 +222,53 @@ export default function JoinBetaPage() {
|
|||||||
</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" ? (
|
||||||
|
<Calendar className="size-5 text-primary" />
|
||||||
|
) : (
|
||||||
<Hash className="size-5 text-primary" />
|
<Hash className="size-5 text-primary" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">编译号</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="font-semibold text-lg">{data.builddate}</p>
|
{data.source === "github" ? "发布时间" : "编译号"}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-lg">
|
||||||
|
{data.source === "github"
|
||||||
|
? data.builddate
|
||||||
|
? new Date(data.builddate).toLocaleDateString("zh-CN")
|
||||||
|
: "未知"
|
||||||
|
: data.builddate}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Cards */}
|
{/* Platform Cards */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">选择平台</h2>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">选择平台</h2>
|
||||||
|
{data.source === "github" &&
|
||||||
|
platforms.some(({ key }) => !!data.app[key].DownlodeURL) && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground">下载源</span>
|
||||||
|
{DOWNLOAD_SOURCES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDownloadSource(s.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs transition-colors",
|
||||||
|
downloadSource === s.key
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{platforms.map(({ key, label, icon: Icon }) => {
|
{platforms.map(({ key, label, icon: Icon }) => {
|
||||||
const platform = data.app[key];
|
const platform = data.app[key];
|
||||||
@@ -211,12 +319,18 @@ export default function JoinBetaPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* About This Version */}
|
{/* Release Notes / About This Version */}
|
||||||
<div className="rounded-2xl border border-border/50 p-6 md:p-8 bg-background/60 backdrop-blur-xl">
|
<div className="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>
|
<h2 className="text-xl font-semibold">
|
||||||
|
{data.releaseNotes ? "版本更新内容" : "关于此版本"}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{data.releaseNotes ? (
|
||||||
|
<MarkdownLite text={data.releaseNotes} />
|
||||||
|
) : data.aboutversion ? (
|
||||||
|
<>
|
||||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||||
{data.aboutversion.about}
|
{data.aboutversion.about}
|
||||||
</p>
|
</p>
|
||||||
@@ -230,6 +344,8 @@ export default function JoinBetaPage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* License Dialog */}
|
{/* License Dialog */}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量 Markdown 渲染器(无依赖)
|
||||||
|
* 用于渲染 GitHub Release Notes 这类常见格式:
|
||||||
|
* 标题、无序/有序列表、引用、代码块、粗体、行内代码、链接、分隔线、段落
|
||||||
|
*/
|
||||||
|
|
||||||
|
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const regex = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while ((m = regex.exec(text)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
parts.push(text.slice(last, m.index));
|
||||||
|
}
|
||||||
|
const tok = m[0];
|
||||||
|
if (tok.startsWith("**")) {
|
||||||
|
parts.push(
|
||||||
|
<strong key={`${keyPrefix}-b${i}`} className="font-semibold">
|
||||||
|
{tok.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (tok.startsWith("`")) {
|
||||||
|
parts.push(
|
||||||
|
<code
|
||||||
|
key={`${keyPrefix}-c${i}`}
|
||||||
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
||||||
|
>
|
||||||
|
{tok.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const link = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||||
|
if (link) {
|
||||||
|
parts.push(
|
||||||
|
<a
|
||||||
|
key={`${keyPrefix}-l${i}`}
|
||||||
|
href={link[2]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-3 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
{link[1]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = m.index + tok.length;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
parts.push(text.slice(last));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownLite({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const blocks: React.ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
let inCode = false;
|
||||||
|
let codeBuf: string[] = [];
|
||||||
|
|
||||||
|
const push = (node: React.ReactNode) => {
|
||||||
|
blocks.push(<div key={key++}>{node}</div>);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 围栏代码块
|
||||||
|
if (/^```/.test(line.trim())) {
|
||||||
|
if (inCode) {
|
||||||
|
push(
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-border/50 bg-muted/40 p-3 font-mono text-xs leading-relaxed">
|
||||||
|
{codeBuf.join("\n")}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
codeBuf = [];
|
||||||
|
inCode = false;
|
||||||
|
} else {
|
||||||
|
inCode = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode) {
|
||||||
|
codeBuf.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue; // 空行跳过
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
const lv = heading[1].length;
|
||||||
|
const content = renderInline(heading[2], `h${key}`);
|
||||||
|
const cls = {
|
||||||
|
1: "text-xl font-bold tracking-tight",
|
||||||
|
2: "text-lg font-bold tracking-tight",
|
||||||
|
3: "text-base font-semibold",
|
||||||
|
4: "text-sm font-semibold",
|
||||||
|
}[lv as 1 | 2 | 3 | 4];
|
||||||
|
push(<h3 className={cn("mb-1 mt-2", cls)}>{content}</h3>);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
if (/^-{3,}$/.test(trimmed)) {
|
||||||
|
push(<hr className="my-3 border-border/60" />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用
|
||||||
|
if (/^>/.test(trimmed)) {
|
||||||
|
push(
|
||||||
|
<blockquote className="my-1 border-l-2 border-primary/50 pl-3 text-muted-foreground">
|
||||||
|
{renderInline(trimmed.replace(/^>\s?/, ""), `q${key}`)}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无序列表
|
||||||
|
const ul = trimmed.match(/^[-*]\s+(.*)$/);
|
||||||
|
if (ul) {
|
||||||
|
push(
|
||||||
|
<div className="flex items-start gap-2 py-0.5 text-sm">
|
||||||
|
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{renderInline(ul[1], `ul${key}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有序列表
|
||||||
|
const ol = trimmed.match(/^\d+\.\s+(.*)$/);
|
||||||
|
if (ol) {
|
||||||
|
push(
|
||||||
|
<div className="flex items-start gap-2 py-0.5 text-sm">
|
||||||
|
<span className="shrink-0 font-mono text-xs leading-5 text-primary/80">
|
||||||
|
{trimmed.match(/^\d+/)?.[0]}.
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{renderInline(ol[1], `ol${key}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通段落
|
||||||
|
push(
|
||||||
|
<p className="py-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{renderInline(trimmed, `p${key}`)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("flex flex-col", className)}>{blocks}</div>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const navItems = [
|
|||||||
textColor: "#fff",
|
textColor: "#fff",
|
||||||
links: [
|
links: [
|
||||||
{ label: "Koring Launcher", href: "/launcher", ariaLabel: "Koring Launcher" },
|
{ label: "Koring Launcher", href: "/launcher", ariaLabel: "Koring Launcher" },
|
||||||
|
{ label: "下载中心", href: "/download", ariaLabel: "下载中心" },
|
||||||
{ label: "Sanshe Play", href: "https://docs.play.lenjing.work", ariaLabel: "Sanshe Play" },
|
{ label: "Sanshe Play", href: "https://docs.play.lenjing.work", ariaLabel: "Sanshe Play" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 下载源列表:GitHub 官方直链 + 国内镜像(格式 {镜像}/{GitHub 直链}) */
|
||||||
|
export const DOWNLOAD_SOURCES = [
|
||||||
|
{ key: "github", label: "GitHub 官方", url: (u: string) => u },
|
||||||
|
{ key: "ddlc", label: "镜像 · ddlc", url: (u: string) => `https://gh.ddlc.top/${u}` },
|
||||||
|
{ key: "proxy", label: "镜像 · proxy", url: (u: string) => `https://gh-proxy.com/${u}` },
|
||||||
|
{ key: "fast", label: "镜像 · fast", url: (u: string) => `https://ghfast.top/${u}` },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DownloadSourceKey = (typeof DOWNLOAD_SOURCES)[number]["key"];
|
||||||
|
|
||||||
|
export function resolveDownloadUrl(source: DownloadSourceKey, url: string) {
|
||||||
|
const s = DOWNLOAD_SOURCES.find((d) => d.key === source);
|
||||||
|
return s ? s.url(url) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端拉取 GitHub 数据(yml / release 页面)时使用的镜像前缀 */
|
||||||
|
export const DATA_MIRRORS = [
|
||||||
|
"https://gh.ddlc.top",
|
||||||
|
"https://gh-proxy.com",
|
||||||
|
"https://ghfast.top",
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { DATA_MIRRORS } from "./download-sources";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Releases 共享辅助函数
|
||||||
|
* 发布方案见《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
|
||||||
|
* electron-builder 发布产物:latest.yml / latest-beta.yml + koring-launcher-{version}-setup.exe
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REPO = "dream-pep/koring-launcher";
|
||||||
|
export const GITHUB_API = `https://api.github.com/repos/${REPO}/releases?per_page=100`;
|
||||||
|
export const GITHUB_API_PROXY = `https://gh-proxy.com/https://api.github.com/repos/${REPO}/releases?per_page=100`;
|
||||||
|
export 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 */
|
||||||
|
export const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i;
|
||||||
|
|
||||||
|
interface FetchResult {
|
||||||
|
ok: boolean;
|
||||||
|
text: string;
|
||||||
|
json: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRaw(
|
||||||
|
url: string,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// 非 JSON
|
||||||
|
}
|
||||||
|
return { ok: res.ok, text, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次尝试多个源,返回第一个成功的结果(raw: true 时返回文本,否则返回 JSON) */
|
||||||
|
export async function trySources(
|
||||||
|
sources: Array<{ url: string; headers?: Record<string, string>; raw?: boolean }>
|
||||||
|
): Promise<unknown | null> {
|
||||||
|
for (const s of sources) {
|
||||||
|
try {
|
||||||
|
const r = await fetchRaw(s.url, s.headers);
|
||||||
|
if (!r.ok) continue;
|
||||||
|
if (s.raw) return r.text;
|
||||||
|
if (r.json !== null && r.json !== undefined) return r.json;
|
||||||
|
} catch {
|
||||||
|
// 继续尝试下一个源
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取 release 列表(直连 -> 镜像代理) */
|
||||||
|
export async function fetchReleases(): Promise<any[] | null> {
|
||||||
|
const releases = await trySources([
|
||||||
|
{ url: GITHUB_API, headers: GH_HEADERS },
|
||||||
|
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
|
||||||
|
]);
|
||||||
|
return Array.isArray(releases) ? releases : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byPublishedDesc(a: any, b: any) {
|
||||||
|
const ta = String(a.published_at || a.created_at || "");
|
||||||
|
const tb = String(b.published_at || b.created_at || "");
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最新预览版 release(prerelease 标记或 -beta.N tag) */
|
||||||
|
export function pickBetaRelease(releases: any[]) {
|
||||||
|
const betas = (releases || []).filter(
|
||||||
|
(r) =>
|
||||||
|
r &&
|
||||||
|
!r.draft &&
|
||||||
|
(r.prerelease === true || BETA_TAG.test(String(r.tag_name || "")))
|
||||||
|
);
|
||||||
|
betas.sort(byPublishedDesc);
|
||||||
|
return betas[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最新正式版 release(非 prerelease、非 draft) */
|
||||||
|
export function pickStableRelease(releases: any[]) {
|
||||||
|
const stables = (releases || []).filter(
|
||||||
|
(r) => r && !r.draft && r.prerelease !== true
|
||||||
|
);
|
||||||
|
stables.sort(byPublishedDesc);
|
||||||
|
return stables[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按文件名匹配 release 资产 */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造 GitHub 下载直链 */
|
||||||
|
export function buildDownloadUrl(tag: string, fileName: string) {
|
||||||
|
return `https://github.com/${REPO}/releases/download/${tag}/${encodeURIComponent(
|
||||||
|
fileName
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 electron-builder 的 latest.yml 格式:
|
||||||
|
* version / files: [{url, sha512, size}] / path / sha512 / releaseDate
|
||||||
|
*/
|
||||||
|
export function parseLatestYml(text: string) {
|
||||||
|
const out: {
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
sha512?: string;
|
||||||
|
size?: number;
|
||||||
|
releaseDate?: string;
|
||||||
|
files: Array<{ url: string; sha512?: string; size?: number }>;
|
||||||
|
} = { files: [] };
|
||||||
|
|
||||||
|
const unquote = (v: string) => v.trim().replace(/^['"](.*)['"]$/, "$1");
|
||||||
|
let currentFile: { url?: string; sha512?: string; size?: number } | null = null;
|
||||||
|
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.replace(/\r$/, "");
|
||||||
|
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||||
|
|
||||||
|
// 列表项: - url: xxx / sha512: yyy
|
||||||
|
const entry = line.match(/^(\s*)-\s+([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||||
|
if (entry) {
|
||||||
|
currentFile = {};
|
||||||
|
out.files.push(currentFile as { url: string });
|
||||||
|
const key = entry[2];
|
||||||
|
const val = unquote(entry[3]);
|
||||||
|
if (key === "url") currentFile.url = val;
|
||||||
|
else if (key === "sha512") currentFile.sha512 = val;
|
||||||
|
else if (key === "size") currentFile.size = Number(val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kv = line.match(/^(\s*)([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||||
|
if (!kv) continue;
|
||||||
|
const indent = kv[1].length;
|
||||||
|
const key = kv[2];
|
||||||
|
const val = unquote(kv[3]);
|
||||||
|
|
||||||
|
if (indent === 0) {
|
||||||
|
if (key === "version") out.version = val;
|
||||||
|
else if (key === "path") out.path = val;
|
||||||
|
else if (key === "sha512") out.sha512 = val;
|
||||||
|
else if (key === "size") out.size = Number(val);
|
||||||
|
else if (key === "releaseDate") out.releaseDate = val;
|
||||||
|
} else if (currentFile) {
|
||||||
|
if (key === "sha512") currentFile.sha512 = val;
|
||||||
|
else if (key === "size") currentFile.size = Number(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取 release 的 latest.yml / latest-beta.yml 内容并解析
|
||||||
|
* (直连 -> 镜像兜底,自动过滤 HTML 垃圾页)
|
||||||
|
*/
|
||||||
|
export async function fetchYmlForRelease(
|
||||||
|
release: any,
|
||||||
|
names: string[]
|
||||||
|
): Promise<ReturnType<typeof parseLatestYml> | null> {
|
||||||
|
if (!release) return null;
|
||||||
|
const assets = release.assets || [];
|
||||||
|
const asset = names
|
||||||
|
.map((n) => assets.find((a: any) => a?.name === n))
|
||||||
|
.find(Boolean);
|
||||||
|
if (!asset?.browser_download_url) return null;
|
||||||
|
|
||||||
|
const text = await trySources([
|
||||||
|
{ url: asset.browser_download_url, headers: GH_HEADERS, raw: true },
|
||||||
|
...DATA_MIRRORS.map((m) => ({
|
||||||
|
url: `${m}/${asset.browser_download_url}`,
|
||||||
|
raw: true,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (typeof text !== "string") return null;
|
||||||
|
const parsed = parseLatestYml(text);
|
||||||
|
return parsed.version ? parsed : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user