feat: support clash subscription imports

This commit is contained in:
JetSprow
2026-04-29 14:49:18 +10:00
parent 68eac100f2
commit d7681240bb
8 changed files with 517 additions and 45 deletions

View File

@@ -1,10 +1,10 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { CalendarClock, Gauge, Layers3, Sparkles } from "lucide-react";
import { QrPreview } from "@/components/shared/qr-preview";
import { Progress } from "@/components/ui/progress";
import { formatBytes } from "@/lib/utils";
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
interface AggregateSubscriptionCardProps {
subscriptionUrl: string;
@@ -24,6 +24,7 @@ export function AggregateSubscriptionCard({
const percent = totalLimit
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
: 0;
const clashUrl = withSubscriptionFormat(subscriptionUrl, "clash");
return (
<section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6">
@@ -44,17 +45,7 @@ export function AggregateSubscriptionCard({
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/80">{subscriptionUrl}</p>
</div>
<CopyButton text={subscriptionUrl} />
</div>
</div>
<SubscriptionImportActions genericUrl={subscriptionUrl} clashUrl={clashUrl} />
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
</div>
<div className="lg:sticky lg:top-24">
<QrPreview label="订阅二维码" value={subscriptionUrl} alt="订阅链接二维码" />
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅链接二维码" />
</div>
</div>
</section>

View File

@@ -1,10 +1,10 @@
import { Gauge, Link2, QrCode } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { CopyButton } from "@/components/shared/copy-button";
import { QrPreview } from "@/components/shared/qr-preview";
import { formatBytes } from "@/lib/utils";
import { buildSingleNodeUri } from "@/services/subscription";
import type { SubscriptionRecord } from "../subscriptions-types";
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
interface ProxySubscriptionDetailsProps {
sub: SubscriptionRecord;
@@ -18,6 +18,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
const clashUrl = withSubscriptionFormat(subUrl, "clash");
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
return (
@@ -45,18 +46,20 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
<div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4 text-primary" />
</div>
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/45 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
</div>
<CopyButton text={subUrl} />
<div className="mt-3">
<SubscriptionImportActions
genericUrl={subUrl}
clashUrl={clashUrl}
title="单节点导入"
description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。"
compact
/>
</div>
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<QrCode className="size-3.5" />
</div>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<QrPreview label="订阅 URL 二维码" value={subUrl} alt="订阅 URL 二维码" />
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅二维码" />
{singleNodeUri && (
<QrPreview
label="单节点 URI 二维码"

View File

@@ -0,0 +1,71 @@
import { Download, ExternalLink, FileCode2, Link2 } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface SubscriptionImportActionsProps {
genericUrl: string;
clashUrl: string;
title?: string;
description?: string;
compact?: boolean;
}
function buildClashImportUrl(url: string) {
return `clash://install-config?url=${encodeURIComponent(url)}`;
}
export function withSubscriptionFormat(url: string, format: "base64" | "uri" | "clash") {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}format=${format}`;
}
export function SubscriptionImportActions({
genericUrl,
clashUrl,
title = "客户端导入",
description = "Clash 使用 YAML 订阅;其他客户端可继续使用通用 Base64 链接。",
compact = false,
}: SubscriptionImportActionsProps) {
const clashImportUrl = buildClashImportUrl(clashUrl);
return (
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className={cn("flex flex-col gap-3", compact ? "" : "lg:flex-row lg:items-center lg:justify-between")}>
<div className="min-w-0 space-y-1">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="truncate font-mono text-xs text-foreground/80">{genericUrl}</p>
<p className="text-xs leading-5 text-muted-foreground">
<span className="font-semibold text-foreground">{title}</span> · {description}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:flex lg:shrink-0 lg:flex-wrap lg:justify-end">
<a
href={clashImportUrl}
className={cn(buttonVariants({ size: "sm" }), "sm:col-span-2 lg:col-span-1")}
>
<ExternalLink className="size-3.5" /> Clash
</a>
<CopyButton text={clashUrl} label="复制 Clash" />
<CopyButton text={genericUrl} label="复制通用" />
<a
href={clashUrl}
download
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<FileCode2 className="size-3.5" /> YAML
</a>
<a
href={genericUrl}
download
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
<Download className="size-3.5" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import { getAggregateSubscriptionToken } from "@/services/subscription";
export const metadata: Metadata = {
title: "我的订阅",
description: "管理活跃订阅并查看历史记录。",
description: "管理活跃订阅,并按 Clash、通用订阅等格式导入客户端。",
};
export default async function SubscriptionsPage() {
@@ -39,7 +39,7 @@ export default async function SubscriptionsPage() {
<PageHeader
eyebrow="订阅管理"
title="我的订阅"
description="总订阅链接负责导入全部代理节点;单个节点卡片只保留状态和快捷操作。"
description="总订阅链接负责导入全部代理节点;Clash 可一键导入 YAML其他客户端继续使用通用订阅。"
/>
<SubscriptionMetrics

View File

@@ -1,5 +1,11 @@
import { NextResponse } from "next/server";
import { generateSubscriptionContent } from "@/services/subscription";
import {
buildSubscriptionUserInfo,
generateSubscriptionContent,
getSubscriptionContentType,
getSubscriptionFilename,
resolveSubscriptionFormat,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
export async function GET(
@@ -26,11 +32,24 @@ export async function GET(
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
}
const content = await generateSubscriptionContent(id);
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
const content = await generateSubscriptionContent(id, format);
const userInfo = buildSubscriptionUserInfo({
upload: 0,
download: sub.trafficUsed,
total: sub.trafficLimit,
expire: sub.endDate,
});
const headers = new Headers({
"Content-Type": getSubscriptionContentType(format),
"Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-sub", format)}"`,
"Cache-Control": "no-store",
"profile-update-interval": "12",
"profile-web-page-url": `${url.origin}/subscriptions/${id}`,
});
if (userInfo) headers.set("Subscription-Userinfo", userInfo);
return new Response(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
},
headers,
});
}

View File

@@ -1,8 +1,13 @@
import { NextResponse } from "next/server";
import {
buildSubscriptionUserInfo,
generateAggregateSubscriptionContent,
getSubscriptionContentType,
getSubscriptionFilename,
resolveSubscriptionFormat,
verifyAggregateSubscriptionToken,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const url = new URL(req.url);
@@ -17,12 +22,47 @@ export async function GET(req: Request) {
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
}
const content = await generateAggregateSubscriptionContent(userId);
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
const activeProxyWhere = {
userId,
status: "ACTIVE" as const,
endDate: { gt: new Date() },
plan: { type: "PROXY" as const },
nodeClient: { isNot: null },
};
const [content, statsRows] = await Promise.all([
generateAggregateSubscriptionContent(userId, format),
prisma.userSubscription.findMany({
where: activeProxyWhere,
select: {
trafficUsed: true,
trafficLimit: true,
endDate: true,
},
}),
]);
const hasUnlimitedTraffic = statsRows.some((row) => row.trafficLimit == null);
const userInfo = buildSubscriptionUserInfo({
upload: 0,
download: statsRows.reduce((sum, row) => sum + row.trafficUsed, BigInt(0)),
total: hasUnlimitedTraffic
? null
: statsRows.reduce((sum, row) => sum + (row.trafficLimit ?? BigInt(0)), BigInt(0)),
expire: statsRows.reduce<Date | null>((earliest, row) => {
if (!earliest || row.endDate < earliest) return row.endDate;
return earliest;
}, null),
});
const headers = new Headers({
"Content-Type": getSubscriptionContentType(format),
"Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-all-sub", format)}"`,
"Cache-Control": "no-store",
"profile-update-interval": "12",
"profile-web-page-url": `${url.origin}/subscriptions`,
});
if (userInfo) headers.set("Subscription-Userinfo", userInfo);
return new Response(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="jboard-all-sub.txt"',
"Cache-Control": "no-store",
},
headers,
});
}