mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: support clash subscription imports
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 二维码"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user