feat(download): 新增感谢下载页面并重构下载流程

- 新增专用的感谢下载页面,支持自动触发下载、SHA512校验值复制与手动重试下载
- 新增下载链接白名单校验机制,避免不安全的任意地址跳转与下载
- 重构官方下载页与测试版申请页面的下载逻辑,统一通过感谢页面完成全流程下载
This commit is contained in:
2026-09-05 20:02:23 +08:00
parent 5997b1c7ea
commit fc6ed1631a
6 changed files with 331 additions and 13 deletions
+27 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -25,7 +26,9 @@ import { PlatformDownloadGrid } from "@/components/platform-download-grid";
import {
DOWNLOAD_SOURCES,
resolveDownloadUrl,
buildDownloadThanksUrl,
type DownloadSourceKey,
type DownloadOsKey,
} from "@/lib/download-sources";
interface ChannelPlatform {
@@ -64,6 +67,7 @@ type Selection =
| { kind: "version"; version: string };
export default function DownloadPage() {
const router = useRouter();
const [data, setData] = useState<DownloadData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -136,9 +140,26 @@ export default function DownloadPage() {
}
};
const handlePlatformDownload = (url?: string | null) => {
if (!url) return;
window.open(resolveDownloadUrl(downloadSource, url), "_blank");
/** 点击下载 -> 跳转感谢下载页(携带 链接/平台/SHA512/加速类型),由该页自动下载 */
const startThanksDownload = (os: DownloadOsKey) => {
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) {
@@ -394,7 +415,9 @@ export default function DownloadPage() {
: null,
},
]}
onDownload={(p) => handlePlatformDownload(p.download?.url)}
onDownload={(p) =>
startThanksDownload(p.key as DownloadOsKey)
}
/>
</div>
</div>
+25 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -32,7 +33,9 @@ import { PlatformDownloadGrid } from "@/components/platform-download-grid";
import {
DOWNLOAD_SOURCES,
resolveDownloadUrl,
buildDownloadThanksUrl,
type DownloadSourceKey,
type DownloadOsKey,
} from "@/lib/download-sources";
interface License {
@@ -88,11 +91,13 @@ interface BetaData {
const PLATFORM_SLOTS = ["windows", "macos", "linux"] as const;
export default function JoinBetaPage() {
const router = useRouter();
const [data, setData] = useState<BetaData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
const [pendingOs, setPendingOs] = useState<DownloadOsKey>("windows");
const [downloadSource, setDownloadSource] = useState<DownloadSourceKey>("github");
/** null = 最新预览版(顶层数据),否则为 data.versions 中的历史版本号 */
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
@@ -132,23 +137,35 @@ export default function JoinBetaPage() {
const formatDate = (d?: string) =>
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") {
setActivePlatform(platform);
setPendingOs(os);
setDialogOpen(true);
} else {
window.open(
resolveDownloadUrl(downloadSource, platform.DownlodeURL),
"_blank"
);
goThanks(url, os);
}
};
const confirmDownload = () => {
if (activePlatform?.DownlodeURL) {
window.open(
goThanks(
resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL),
"_blank"
pendingOs
);
}
setDialogOpen(false);
@@ -352,7 +369,7 @@ export default function JoinBetaPage() {
]}
onDownload={(slot) => {
const info = active.app[slot.key as keyof AppInfo];
if (info?.DownlodeURL) handleDownload(info);
if (info?.DownlodeURL) handleDownload(info, slot.key as DownloadOsKey);
}}
/>
</div>
+22
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}