mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
feat(download): 新增感谢下载页面并重构下载流程
- 新增专用的感谢下载页面,支持自动触发下载、SHA512校验值复制与手动重试下载 - 新增下载链接白名单校验机制,避免不安全的任意地址跳转与下载 - 重构官方下载页与测试版申请页面的下载逻辑,统一通过感谢页面完成全流程下载
This commit is contained in:
+27
-4
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
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 {
|
||||||
@@ -25,7 +26,9 @@ import { PlatformDownloadGrid } from "@/components/platform-download-grid";
|
|||||||
import {
|
import {
|
||||||
DOWNLOAD_SOURCES,
|
DOWNLOAD_SOURCES,
|
||||||
resolveDownloadUrl,
|
resolveDownloadUrl,
|
||||||
|
buildDownloadThanksUrl,
|
||||||
type DownloadSourceKey,
|
type DownloadSourceKey,
|
||||||
|
type DownloadOsKey,
|
||||||
} from "@/lib/download-sources";
|
} from "@/lib/download-sources";
|
||||||
|
|
||||||
interface ChannelPlatform {
|
interface ChannelPlatform {
|
||||||
@@ -64,6 +67,7 @@ type Selection =
|
|||||||
| { kind: "version"; version: string };
|
| { kind: "version"; version: string };
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default function DownloadPage() {
|
||||||
|
const router = useRouter();
|
||||||
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);
|
||||||
@@ -136,9 +140,26 @@ export default function DownloadPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlatformDownload = (url?: string | null) => {
|
/** 点击下载 -> 跳转感谢下载页(携带 链接/平台/SHA512/加速类型),由该页自动下载 */
|
||||||
if (!url) return;
|
const startThanksDownload = (os: DownloadOsKey) => {
|
||||||
window.open(resolveDownloadUrl(downloadSource, url), "_blank");
|
const info = (
|
||||||
|
{
|
||||||
|
windows: active?.windows,
|
||||||
|
macos: active?.macos,
|
||||||
|
linux: active?.linux,
|
||||||
|
} as const
|
||||||
|
)[os];
|
||||||
|
if (!info?.url) return;
|
||||||
|
router.push(
|
||||||
|
buildDownloadThanksUrl({
|
||||||
|
url: resolveDownloadUrl(downloadSource, info.url),
|
||||||
|
os,
|
||||||
|
source: downloadSource,
|
||||||
|
sha512: info.sha512,
|
||||||
|
name: info.name,
|
||||||
|
version: active?.version,
|
||||||
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -394,7 +415,9 @@ export default function DownloadPage() {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onDownload={(p) => handlePlatformDownload(p.download?.url)}
|
onDownload={(p) =>
|
||||||
|
startThanksDownload(p.key as DownloadOsKey)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
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 {
|
||||||
@@ -32,7 +33,9 @@ import { PlatformDownloadGrid } from "@/components/platform-download-grid";
|
|||||||
import {
|
import {
|
||||||
DOWNLOAD_SOURCES,
|
DOWNLOAD_SOURCES,
|
||||||
resolveDownloadUrl,
|
resolveDownloadUrl,
|
||||||
|
buildDownloadThanksUrl,
|
||||||
type DownloadSourceKey,
|
type DownloadSourceKey,
|
||||||
|
type DownloadOsKey,
|
||||||
} from "@/lib/download-sources";
|
} from "@/lib/download-sources";
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
@@ -88,11 +91,13 @@ interface BetaData {
|
|||||||
const PLATFORM_SLOTS = ["windows", "macos", "linux"] as const;
|
const PLATFORM_SLOTS = ["windows", "macos", "linux"] as const;
|
||||||
|
|
||||||
export default function JoinBetaPage() {
|
export default function JoinBetaPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [data, setData] = useState<BetaData | null>(null);
|
const [data, setData] = useState<BetaData | 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 [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
|
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
|
||||||
|
const [pendingOs, setPendingOs] = useState<DownloadOsKey>("windows");
|
||||||
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
|
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
|
||||||
/** null = 最新预览版(顶层数据),否则为 data.versions 中的历史版本号 */
|
/** null = 最新预览版(顶层数据),否则为 data.versions 中的历史版本号 */
|
||||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||||
@@ -132,23 +137,35 @@ export default function JoinBetaPage() {
|
|||||||
const formatDate = (d?: string) =>
|
const formatDate = (d?: string) =>
|
||||||
d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
|
d ? new Date(d).toLocaleDateString("zh-CN") : "未知";
|
||||||
|
|
||||||
const handleDownload = (platform: PlatformInfo) => {
|
/** 跳转感谢下载页(携带 链接/平台/SHA512/加速类型),由该页自动下载 */
|
||||||
|
const goThanks = (url: string, os: DownloadOsKey) => {
|
||||||
|
router.push(
|
||||||
|
buildDownloadThanksUrl({
|
||||||
|
url,
|
||||||
|
os,
|
||||||
|
source: downloadSource,
|
||||||
|
version: active?.version,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = (platform: PlatformInfo, os: DownloadOsKey) => {
|
||||||
|
if (!platform.DownlodeURL) return;
|
||||||
|
const url = resolveDownloadUrl(downloadSource, platform.DownlodeURL);
|
||||||
if (platform.licence.isNeedAgreat === "true") {
|
if (platform.licence.isNeedAgreat === "true") {
|
||||||
setActivePlatform(platform);
|
setActivePlatform(platform);
|
||||||
|
setPendingOs(os);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
} else {
|
} else {
|
||||||
window.open(
|
goThanks(url, os);
|
||||||
resolveDownloadUrl(downloadSource, platform.DownlodeURL),
|
|
||||||
"_blank"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDownload = () => {
|
const confirmDownload = () => {
|
||||||
if (activePlatform?.DownlodeURL) {
|
if (activePlatform?.DownlodeURL) {
|
||||||
window.open(
|
goThanks(
|
||||||
resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL),
|
resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL),
|
||||||
"_blank"
|
pendingOs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -352,7 +369,7 @@ export default function JoinBetaPage() {
|
|||||||
]}
|
]}
|
||||||
onDownload={(slot) => {
|
onDownload={(slot) => {
|
||||||
const info = active.app[slot.key as keyof AppInfo];
|
const info = active.app[slot.key as keyof AppInfo];
|
||||||
if (info?.DownlodeURL) handleDownload(info);
|
if (info?.DownlodeURL) handleDownload(info, slot.key as DownloadOsKey);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { ThanksView } from "./thanks-view";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "感谢下载 · Koring Launcher",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ThanksPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ThanksView />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
PartyPopper,
|
||||||
|
Download,
|
||||||
|
ShieldCheck,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
RotateCw,
|
||||||
|
Monitor,
|
||||||
|
Apple,
|
||||||
|
Terminal,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_SOURCES,
|
||||||
|
DOWNLOAD_OS_META,
|
||||||
|
isAllowedDownloadUrl,
|
||||||
|
type DownloadOsKey,
|
||||||
|
} from "@/lib/download-sources";
|
||||||
|
|
||||||
|
const OS_ICON: Record<string, LucideIcon> = {
|
||||||
|
windows: Monitor,
|
||||||
|
macos: Apple,
|
||||||
|
linux: Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThanksView() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [autoStarted, setAutoStarted] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const url = searchParams.get("url");
|
||||||
|
const osParam = searchParams.get("os") || "";
|
||||||
|
const sha512 = searchParams.get("sha512") || "";
|
||||||
|
const sourceKey = searchParams.get("source") || "";
|
||||||
|
const name = searchParams.get("name") || "";
|
||||||
|
const version = searchParams.get("version") || "";
|
||||||
|
|
||||||
|
const os = (
|
||||||
|
osParam === "windows" || osParam === "macos" || osParam === "linux"
|
||||||
|
? osParam
|
||||||
|
: "windows"
|
||||||
|
) as DownloadOsKey;
|
||||||
|
const osMeta = DOWNLOAD_OS_META[os];
|
||||||
|
const OsIcon = OS_ICON[os];
|
||||||
|
|
||||||
|
const allowed = isAllowedDownloadUrl(url);
|
||||||
|
const sourceLabel = useMemo(() => {
|
||||||
|
const s = DOWNLOAD_SOURCES.find((d) => d.key === sourceKey);
|
||||||
|
if (s) return s.label;
|
||||||
|
return sourceKey === "legacy" ? "官方缓存源" : sourceKey || "默认源";
|
||||||
|
}, [sourceKey]);
|
||||||
|
|
||||||
|
/** 触发浏览器下载(隐藏 a 标签,同页不离开) */
|
||||||
|
const triggerDownload = () => {
|
||||||
|
if (!allowed || !url) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.rel = "noopener noreferrer";
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动开始下载
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed || !url || autoStarted) return;
|
||||||
|
setAutoStarted(true);
|
||||||
|
const timer = setTimeout(triggerDownload, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [url, allowed]);
|
||||||
|
|
||||||
|
const copySha = async () => {
|
||||||
|
if (!sha512) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(sha512);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// 忽略剪贴板不可用
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[70vh] w-full gap-8 px-4">
|
||||||
|
{/* 主卡片 */}
|
||||||
|
<div className="w-full max-w-xl rounded-3xl border border-border/50 bg-background/60 backdrop-blur-xl p-8 md:p-10 text-center">
|
||||||
|
{allowed ? (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
|
||||||
|
<PartyPopper className="size-8 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
|
感谢下载 Koring Launcher
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{autoStarted
|
||||||
|
? "下载已自动开始,如果未弹出请点击下方按钮"
|
||||||
|
: "下载即将自动开始…"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 下载内容摘要 */}
|
||||||
|
<div className="mt-6 flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<OsIcon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
下载类型 · {osMeta.hint}
|
||||||
|
</p>
|
||||||
|
<p className="truncate font-medium text-sm">
|
||||||
|
{name || `${osMeta.label} 安装包`}
|
||||||
|
{version ? `(v${version})` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Download className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">加速类型</p>
|
||||||
|
<p className="truncate font-medium text-sm">{sourceLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copySha}
|
||||||
|
disabled={!sha512}
|
||||||
|
title={sha512 ? "点击复制 SHA512" : undefined}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border/50 p-3 text-left transition-colors hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<ShieldCheck className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
SHA512 校验值
|
||||||
|
{sha512 && (copied ? "(已复制)" : "(点击复制)")}
|
||||||
|
</p>
|
||||||
|
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{sha512 || "未提供"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作 */}
|
||||||
|
<div className="mt-7 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||||
|
<Button size="lg" onClick={triggerDownload}>
|
||||||
|
<RotateCw className="size-4" />
|
||||||
|
重新下载
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<Link href="/download">
|
||||||
|
返回下载中心
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold tracking-tight">
|
||||||
|
下载链接无效
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
下载地址不在允许范围内,未自动开始下载。
|
||||||
|
</p>
|
||||||
|
<div className="mt-7">
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<Link href="/download">
|
||||||
|
返回下载中心
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
{allowed && (
|
||||||
|
<p className={cn("max-w-xl text-center text-xs text-muted-foreground")}>
|
||||||
|
如浏览器拦截下载,请点击上方"重新下载";安装时建议校验文件 SHA512 完整性。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -19,3 +19,53 @@ export const DATA_MIRRORS = [
|
|||||||
"https://gh-proxy.com",
|
"https://gh-proxy.com",
|
||||||
"https://ghfast.top",
|
"https://ghfast.top",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 允许跳转到感谢页并自动下载的地址前缀(官方直链 + 镜像 + 自建 CDN) */
|
||||||
|
const ALLOWED_DOWNLOAD_PREFIXES = [
|
||||||
|
"https://github.com/dream-pep/koring-launcher/releases/download/",
|
||||||
|
"https://gh.ddlc.top/https://github.com/dream-pep/koring-launcher/releases/download/",
|
||||||
|
"https://gh-proxy.com/https://github.com/dream-pep/koring-launcher/releases/download/",
|
||||||
|
"https://ghfast.top/https://github.com/dream-pep/koring-launcher/releases/download/",
|
||||||
|
"https://koring-file-api.lenjing.games/",
|
||||||
|
"https://koring-launcher-file-api.lenjing.cloud/",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 校验下载链接是否在白名单内(防止任意地址跳转/自动下载) */
|
||||||
|
export function isAllowedDownloadUrl(
|
||||||
|
url: string | null | undefined
|
||||||
|
): url is string {
|
||||||
|
return (
|
||||||
|
typeof url === "string" &&
|
||||||
|
ALLOWED_DOWNLOAD_PREFIXES.some((p) => url.startsWith(p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadOsKey = "windows" | "macos" | "linux";
|
||||||
|
|
||||||
|
export const DOWNLOAD_OS_META: Record<
|
||||||
|
DownloadOsKey,
|
||||||
|
{ label: string; hint: string }
|
||||||
|
> = {
|
||||||
|
windows: { label: "Windows", hint: "Windows 安装包(exe)" },
|
||||||
|
macos: { label: "macOS", hint: "macOS 安装包" },
|
||||||
|
linux: { label: "Linux", hint: "Linux 安装包" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 构造感谢下载页跳转地址(附带 下载链接 / 平台 / SHA512 / 加速类型) */
|
||||||
|
export function buildDownloadThanksUrl(opts: {
|
||||||
|
url: string;
|
||||||
|
os: DownloadOsKey;
|
||||||
|
source: string;
|
||||||
|
sha512?: string;
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
}) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("url", opts.url);
|
||||||
|
p.set("os", opts.os);
|
||||||
|
p.set("source", opts.source);
|
||||||
|
if (opts.sha512) p.set("sha512", opts.sha512);
|
||||||
|
if (opts.name) p.set("name", opts.name);
|
||||||
|
if (opts.version) p.set("version", opts.version);
|
||||||
|
return `/thanks?${p.toString()}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user