mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 09:14:11 +05:30
Initial commit
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { ArrowUpRight, CalendarClock, Database, Radio, Server, Tv } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { SubscriptionActions } from "../subscription-actions";
|
||||
import {
|
||||
getPlanTypeLabel,
|
||||
getPlanTypeTone,
|
||||
getTrafficPoolRemainingGb,
|
||||
} from "../subscriptions-calculations";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
|
||||
|
||||
interface ActiveSubscriptionCardProps {
|
||||
sub: SubscriptionRecord;
|
||||
poolMap: Map<string, PlanTrafficPoolState>;
|
||||
}
|
||||
|
||||
function getInboundDisplayName(sub: SubscriptionRecord) {
|
||||
const settings = sub.nodeClient?.inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return sub.nodeClient?.inbound.tag ?? "等待分配";
|
||||
}
|
||||
|
||||
function ProxyCompactSummary({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "PROXY") return null;
|
||||
|
||||
const used = Number(sub.trafficUsed);
|
||||
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
|
||||
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
const nodeName = sub.nodeClient?.inbound.server.name ?? sub.plan.node?.name ?? "待分配";
|
||||
const inboundName = getInboundDisplayName(sub);
|
||||
const protocol = sub.nodeClient?.inbound.protocol ?? "—";
|
||||
const port = sub.nodeClient?.inbound.port ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-3">
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Server className="size-3.5 text-primary" /> 节点
|
||||
</p>
|
||||
<p className="mt-1 truncate font-semibold">{nodeName}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Radio className="size-3.5 text-primary" /> 入站
|
||||
</p>
|
||||
<p className="mt-1 truncate font-semibold">{protocol}{port ? ` · ${port}` : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
<Database className="size-3.5 text-primary" />
|
||||
<span className="truncate">{inboundName}</span>
|
||||
</span>
|
||||
{limit && <span className="font-semibold text-primary tabular-nums">{percent}%</span>}
|
||||
</div>
|
||||
{limit ? (
|
||||
<>
|
||||
<Progress value={percent} />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatBytes(used)} / {formatBytes(limit)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">不限流量 · 已用 {formatBytes(used)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingCompactSummary({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "STREAMING") return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/25 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Tv className="size-3.5 text-primary" /> 流媒体服务
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">{sub.streamingSlot?.service.name ?? "账号分配中"}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">账号凭据和使用说明请进入详情页查看。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveSubscriptionCard({ sub, poolMap }: ActiveSubscriptionCardProps) {
|
||||
return (
|
||||
<Card className="group overflow-hidden transition-colors duration-200 hover:border-primary/25 hover:bg-muted/10">
|
||||
<CardHeader className="gap-3 p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{sub.plan.type === "PROXY" ? <Radio className="size-4" /> : <Tv className="size-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<CardTitle className="text-base">
|
||||
<Link
|
||||
href={`/subscriptions/${sub.id}`}
|
||||
className="group/link inline-flex max-w-full items-center gap-1.5 hover:text-primary"
|
||||
>
|
||||
<span className="truncate">{sub.plan.name}</span>
|
||||
<ArrowUpRight className="size-3.5 opacity-45 transition-transform duration-300 group-hover/link:-translate-y-0.5 group-hover/link:translate-x-0.5 group-hover/link:opacity-100" />
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="inline-flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })} 到期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge tone={getPlanTypeTone(sub.plan.type)} className="h-6 px-2 text-[11px]">
|
||||
{getPlanTypeLabel(sub.plan.type)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-1">
|
||||
<ProxyCompactSummary sub={sub} />
|
||||
<StreamingCompactSummary sub={sub} />
|
||||
<SubscriptionActions
|
||||
subscriptionId={sub.id}
|
||||
type={sub.plan.type}
|
||||
allowRenewal={sub.plan.allowRenewal}
|
||||
allowTrafficTopup={sub.plan.type === "PROXY" && sub.plan.allowTrafficTopup}
|
||||
trafficPoolRemainingGb={getTrafficPoolRemainingGb(sub, poolMap)}
|
||||
renewalConfig={{
|
||||
durationDays: sub.plan.durationDays,
|
||||
renewalPrice: sub.plan.renewalPrice == null ? null : Number(sub.plan.renewalPrice),
|
||||
renewalPricingMode: sub.plan.renewalPricingMode,
|
||||
renewalDurationDays: sub.plan.renewalDurationDays,
|
||||
renewalMinDays: sub.plan.renewalMinDays,
|
||||
renewalMaxDays: sub.plan.renewalMaxDays,
|
||||
}}
|
||||
topupConfig={{
|
||||
topupPricingMode: sub.plan.topupPricingMode,
|
||||
topupPricePerGb: sub.plan.topupPricePerGb == null ? null : Number(sub.plan.topupPricePerGb),
|
||||
topupFixedPrice: sub.plan.topupFixedPrice == null ? null : Number(sub.plan.topupFixedPrice),
|
||||
minTopupGb: sub.plan.minTopupGb,
|
||||
maxTopupGb: sub.plan.maxTopupGb,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { Film, Radio, ShoppingBag } from "lucide-react";
|
||||
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { CollapsibleGroup } from "@/components/shared/collapsible-group";
|
||||
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import { ActiveSubscriptionCard } from "./active-subscription-card";
|
||||
import { AggregateSubscriptionCard } from "./aggregate-subscription-card";
|
||||
|
||||
interface ActiveSubscriptionsSectionProps {
|
||||
subscriptions: SubscriptionRecord[];
|
||||
aggregateSubscriptionUrl: string | null;
|
||||
poolMap: Map<string, PlanTrafficPoolState>;
|
||||
}
|
||||
|
||||
function toBigInt(value: bigint | number | null | undefined) {
|
||||
if (typeof value === "bigint") return value;
|
||||
return BigInt(value ?? 0);
|
||||
}
|
||||
|
||||
function groupSubscriptions(subscriptions: SubscriptionRecord[], type: "PROXY" | "STREAMING") {
|
||||
const groups = new Map<string, { title: string; subtitle: string; items: SubscriptionRecord[] }>();
|
||||
for (const sub of subscriptions.filter((item) => item.plan.type === type)) {
|
||||
const key = sub.plan.category?.id ?? `default-${type}`;
|
||||
const fallbackTitle = type === "PROXY" ? "代理连接" : "流媒体共享";
|
||||
const group = groups.get(key) ?? {
|
||||
title: sub.plan.category?.name ?? fallbackTitle,
|
||||
subtitle: "",
|
||||
items: [],
|
||||
};
|
||||
group.items.push(sub);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.values()).map((g) => ({
|
||||
...g,
|
||||
subtitle: `${g.items.length} 个订阅`,
|
||||
}));
|
||||
}
|
||||
|
||||
function getProxySummary(subscriptions: SubscriptionRecord[]) {
|
||||
const proxySubscriptions = subscriptions.filter((sub) => sub.plan.type === "PROXY" && sub.nodeClient);
|
||||
let totalUsed = BigInt(0);
|
||||
let totalLimit = BigInt(0);
|
||||
let hasUnlimited = false;
|
||||
let nextExpiry: Date | null = null;
|
||||
|
||||
for (const sub of proxySubscriptions) {
|
||||
totalUsed += toBigInt(sub.trafficUsed);
|
||||
if (sub.trafficLimit == null) {
|
||||
hasUnlimited = true;
|
||||
} else {
|
||||
totalLimit += toBigInt(sub.trafficLimit);
|
||||
}
|
||||
if (!nextExpiry || sub.endDate < nextExpiry) {
|
||||
nextExpiry = sub.endDate;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount: proxySubscriptions.length,
|
||||
totalUsed,
|
||||
totalLimit: hasUnlimited ? null : totalLimit,
|
||||
nextExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
export function ActiveSubscriptionsSection({
|
||||
subscriptions,
|
||||
aggregateSubscriptionUrl,
|
||||
poolMap,
|
||||
}: ActiveSubscriptionsSectionProps) {
|
||||
const proxyGroups = groupSubscriptions(subscriptions, "PROXY");
|
||||
const streamingGroups = groupSubscriptions(subscriptions, "STREAMING");
|
||||
const proxySummary = getProxySummary(subscriptions);
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SectionHeader title="活跃订阅" />
|
||||
{subscriptions.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="下一步"
|
||||
icon={<ShoppingBag className="size-5" />}
|
||||
title="还没有正在使用的订阅"
|
||||
description="选择套餐并完成支付后,这里会显示统一订阅链接、节点概览和续费入口。"
|
||||
action={
|
||||
<Link href="/store" className={buttonVariants()}>
|
||||
去商店选择套餐
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-7">
|
||||
{proxySummary.nodeCount > 0 && aggregateSubscriptionUrl && (
|
||||
<AggregateSubscriptionCard
|
||||
subscriptionUrl={aggregateSubscriptionUrl}
|
||||
nodeCount={proxySummary.nodeCount}
|
||||
totalUsed={proxySummary.totalUsed}
|
||||
totalLimit={proxySummary.totalLimit}
|
||||
nextExpiry={proxySummary.nextExpiry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{proxyGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="节点概览"
|
||||
description="节点卡片只保留状态、流量和操作;配置、二维码和日志放到详情页。"
|
||||
actions={<Radio className="size-5 text-primary" />}
|
||||
/>
|
||||
{proxyGroups.map((group, index) => (
|
||||
<CollapsibleGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
subtitle={group.subtitle}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((sub) => (
|
||||
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamingGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="流媒体服务"
|
||||
description="账号信息已收进详情页,列表只显示可用状态。"
|
||||
actions={<Film className="size-5 text-primary" />}
|
||||
/>
|
||||
{streamingGroups.map((group, index) => (
|
||||
<CollapsibleGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
subtitle={group.subtitle}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((sub) => (
|
||||
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 { QrPreview } from "@/components/shared/qr-preview";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
|
||||
interface AggregateSubscriptionCardProps {
|
||||
subscriptionUrl: string;
|
||||
nodeCount: number;
|
||||
totalUsed: bigint;
|
||||
totalLimit: bigint | null;
|
||||
nextExpiry: Date | null;
|
||||
}
|
||||
|
||||
export function AggregateSubscriptionCard({
|
||||
subscriptionUrl,
|
||||
nodeCount,
|
||||
totalUsed,
|
||||
totalLimit,
|
||||
nextExpiry,
|
||||
}: AggregateSubscriptionCardProps) {
|
||||
const percent = totalLimit
|
||||
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
|
||||
: 0;
|
||||
|
||||
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">
|
||||
<div className="absolute right-8 top-8 size-32 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="relative grid gap-6 lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
<Sparkles className="size-3.5" /> 总订阅链接
|
||||
</div>
|
||||
<div className="max-w-2xl space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-balance sm:text-3xl">
|
||||
一个链接导入全部节点
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||
新购买的代理节点会自动加入这个链接;到期或停用的节点会自动从订阅内容中移除,不需要重复导入。
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Layers3 className="size-3.5 text-primary" /> 已合并节点
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{nodeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Gauge className="size-3.5 text-primary" /> 总用量
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{formatBytes(totalUsed)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CalendarClock className="size-3.5 text-primary" /> 最近到期
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
{nextExpiry ? format(nextExpiry, "MM-dd HH:mm", { locale: zhCN }) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalLimit && (
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>合计流量进度</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{percent}%</span>
|
||||
</div>
|
||||
<Progress value={percent} />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
总额度 {formatBytes(totalLimit)} · 剩余 {formatBytes(totalLimit - totalUsed > BigInt(0) ? totalLimit - totalUsed : BigInt(0))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<QrPreview label="总订阅二维码" value={subscriptionUrl} alt="总订阅链接二维码" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { Archive } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
|
||||
import {
|
||||
getSubscriptionStatusLabel,
|
||||
getSubscriptionStatusTone,
|
||||
} from "../subscriptions-calculations";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
|
||||
export function HistorySubscriptionsSection({
|
||||
subscriptions,
|
||||
}: {
|
||||
subscriptions: SubscriptionRecord[];
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<SectionHeader title="历史记录" />
|
||||
{subscriptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Archive className="size-5" />}
|
||||
title="历史记录还是空的"
|
||||
description="过期、暂停或取消后的订阅会在这里保留记录,方便你之后回看。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{subscriptions.map((sub) => (
|
||||
<Card key={sub.id} className="border-muted-foreground/15">
|
||||
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{sub.plan.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(sub.startDate, "yyyy-MM-dd", { locale: zhCN })} ~{" "}
|
||||
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={getSubscriptionStatusTone(sub.status)}>
|
||||
{getSubscriptionStatusLabel(sub.status)}
|
||||
</StatusBadge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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";
|
||||
|
||||
interface ProxySubscriptionDetailsProps {
|
||||
sub: SubscriptionRecord;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDetailsProps) {
|
||||
if (sub.plan.type !== "PROXY") return null;
|
||||
|
||||
const used = Number(sub.trafficUsed);
|
||||
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 singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{limit && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Gauge className="size-4 text-primary" /> 流量用量
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary tabular-nums">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percent} className="h-2.5" />
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<p className="rounded-xl bg-background/45 px-3 py-2">已用 <span className="font-semibold text-foreground">{formatBytes(used)}</span></p>
|
||||
<p className="rounded-xl bg-background/45 px-3 py-2">剩余 <span className="font-semibold text-foreground">{formatBytes(Math.max(0, limit - used))}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub.nodeClient ? (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<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>
|
||||
<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 二维码" />
|
||||
{singleNodeUri && (
|
||||
<QrPreview
|
||||
label="单节点 URI 二维码"
|
||||
value={singleNodeUri}
|
||||
alt="单节点 URI 二维码"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-3 text-sm leading-6 text-muted-foreground">
|
||||
订阅节点正在准备中,分配完成后这里会展示订阅链接与二维码。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/app/(user)/subscriptions/_components/renewal-button.tsx
Normal file
145
src/app/(user)/subscriptions/_components/renewal-button.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw, WalletCards } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseRenewal } from "@/actions/user/purchase";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface RenewalConfig {
|
||||
durationDays: number;
|
||||
renewalPrice: number | null;
|
||||
renewalPricingMode: string;
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
}
|
||||
|
||||
function clampDuration(value: number, min: number, max: number, step: number) {
|
||||
const clamped = Math.min(max, Math.max(min, value));
|
||||
if (step <= 1) return clamped;
|
||||
const offset = clamped - min;
|
||||
return min + Math.round(offset / step) * step;
|
||||
}
|
||||
|
||||
export function RenewalButton({
|
||||
subscriptionId,
|
||||
config,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
config: RenewalConfig;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPerDay = config.renewalPricingMode === "PER_DAY";
|
||||
const unitDays = isPerDay ? 1 : (config.renewalDurationDays ?? config.durationDays);
|
||||
const minDays = config.renewalMinDays ?? unitDays;
|
||||
const maxDays = Math.max(minDays, config.renewalMaxDays ?? unitDays);
|
||||
const step = isPerDay ? 1 : unitDays;
|
||||
const [renewalDays, setRenewalDays] = useState(minDays);
|
||||
const totalPrice = useMemo(() => {
|
||||
const unitPrice = config.renewalPrice ?? 0;
|
||||
const amount = isPerDay ? unitPrice * renewalDays : unitPrice * Math.max(1, renewalDays / unitDays);
|
||||
return amount.toFixed(2);
|
||||
}, [config.renewalPrice, isPerDay, renewalDays, unitDays]);
|
||||
|
||||
async function handleRenewal() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await purchaseRenewal(subscriptionId, renewalDays);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
router.push(`/pay/${result.orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建续费订单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setRenewalDays(minDays);
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
续费
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<WalletCards className="size-3.5" /> RENEWAL
|
||||
</div>
|
||||
<DialogTitle>续费订阅</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择续费时长,支付成功后自动延长当前订阅有效期。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-4 flex justify-between text-sm">
|
||||
<span className="font-medium">续费时长</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{renewalDays} 天</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[renewalDays]}
|
||||
onValueChange={(values: number | readonly number[]) => {
|
||||
const value = Array.isArray(values) ? values[0] : values;
|
||||
setRenewalDays(clampDuration(value ?? minDays, minDays, maxDays, step));
|
||||
}}
|
||||
min={minDays}
|
||||
max={maxDays}
|
||||
step={step}
|
||||
disabled={maxDays <= minDays}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{minDays} 天</span>
|
||||
<span>{isPerDay ? `¥${(config.renewalPrice ?? 0).toFixed(2)}/天` : `¥${(config.renewalPrice ?? 0).toFixed(2)}/${unitDays}天`}</span>
|
||||
<span>{maxDays} 天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">本次应付</span>
|
||||
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleRenewal}
|
||||
disabled={loading || !config.renewalPrice}
|
||||
>
|
||||
{loading ? "创建中..." : "去支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { rotateSubscriptionAccess } from "@/actions/user/subscription-security";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
|
||||
export function ResetAccessButton({ subscriptionId }: { subscriptionId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
title="重置订阅访问?"
|
||||
description="我们会为这条订阅生成新的访问凭据。旧链接会失效,请在客户端重新导入。"
|
||||
confirmLabel="重置访问"
|
||||
successMessage="订阅访问已重置"
|
||||
errorMessage="重置失败"
|
||||
onConfirm={() => rotateSubscriptionAccess(subscriptionId)}
|
||||
onSuccess={() => router.refresh()}
|
||||
>
|
||||
<ShieldCheck className="size-3.5" />
|
||||
重置访问
|
||||
</ConfirmActionButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import { StreamingCredentialCard } from "../streaming-credential-card";
|
||||
|
||||
export function StreamingSubscriptionDetails({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "STREAMING") return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Tv className="size-4 text-primary" /> 流媒体服务
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary">
|
||||
{sub.streamingSlot?.service.name ?? "待分配"}
|
||||
</span>
|
||||
</div>
|
||||
{sub.streamingSlot ? (
|
||||
<StreamingCredentialCard subscriptionId={sub.id} />
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">服务账号正在分配中,请稍后刷新查看。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface SubscriptionMetricsProps {
|
||||
activeCount: number;
|
||||
historyCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
["活跃", "activeCount"],
|
||||
["历史", "historyCount"],
|
||||
["全部", "totalCount"],
|
||||
] as const;
|
||||
|
||||
export function SubscriptionMetrics({
|
||||
activeCount,
|
||||
historyCount,
|
||||
totalCount,
|
||||
}: SubscriptionMetricsProps) {
|
||||
const values = { activeCount, historyCount, totalCount };
|
||||
|
||||
return (
|
||||
<section className="grid gap-2 sm:grid-cols-3">
|
||||
{metrics.map(([label, key]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded-xl border border-border bg-muted/20 px-4 py-3">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-lg font-semibold tracking-[-0.04em] tabular-nums">{values[key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, WalletCards } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseTrafficTopup } from "@/actions/user/purchase";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface TrafficTopupConfig {
|
||||
topupPricingMode: string;
|
||||
topupPricePerGb: number | null;
|
||||
topupFixedPrice: number | null;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
}
|
||||
|
||||
interface TrafficTopupDialogProps {
|
||||
subscriptionId: string;
|
||||
trafficPoolRemainingGb: number | null;
|
||||
config: TrafficTopupConfig;
|
||||
}
|
||||
|
||||
function getTopupBounds(trafficPoolRemainingGb: number | null, config: TrafficTopupConfig) {
|
||||
const min = Math.max(1, config.minTopupGb ?? 1);
|
||||
const configuredMax = config.maxTopupGb ?? 1000;
|
||||
const poolMax = trafficPoolRemainingGb == null
|
||||
? configuredMax
|
||||
: Math.max(0, Math.floor(trafficPoolRemainingGb));
|
||||
const max = Math.max(0, Math.min(configuredMax, poolMax));
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function TrafficTopupDialog({
|
||||
subscriptionId,
|
||||
trafficPoolRemainingGb,
|
||||
config,
|
||||
}: TrafficTopupDialogProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { min, max } = getTopupBounds(trafficPoolRemainingGb, config);
|
||||
const initialTopupGb = useMemo(() => {
|
||||
if (max <= 0) return min;
|
||||
return Math.min(Math.max(min, 10), max);
|
||||
}, [max, min]);
|
||||
const [topupGb, setTopupGb] = useState(initialTopupGb);
|
||||
const isFixedAmount = config.topupPricingMode === "FIXED_AMOUNT";
|
||||
const totalPrice = useMemo(() => {
|
||||
const amount = isFixedAmount
|
||||
? (config.topupFixedPrice ?? 0)
|
||||
: (config.topupPricePerGb ?? 0) * topupGb;
|
||||
return amount.toFixed(2);
|
||||
}, [config.topupFixedPrice, config.topupPricePerGb, isFixedAmount, topupGb]);
|
||||
const hasPrice = isFixedAmount
|
||||
? (config.topupFixedPrice ?? 0) > 0
|
||||
: (config.topupPricePerGb ?? 0) > 0;
|
||||
const disabled = max <= 0 || max < min || !hasPrice;
|
||||
|
||||
async function handleTopup() {
|
||||
if (disabled) {
|
||||
toast.error(hasPrice ? "当前套餐剩余额度不足,暂不可增流量" : "这款套餐暂未配置增流量价格");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const orderId = await purchaseTrafficTopup(subscriptionId, topupGb);
|
||||
router.push(`/pay/${orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建增流量订单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTopupGb(initialTopupGb);
|
||||
setOpen(true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
增加流量
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<WalletCards className="size-3.5" /> TRAFFIC TOPUP
|
||||
</div>
|
||||
<DialogTitle>增加订阅流量</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择本次增加的流量,支付完成后自动写入当前订阅。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">单次可增范围</span>
|
||||
<span className="font-semibold tabular-nums">{disabled ? "暂无额度" : `${min}-${max} GB`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-4 flex justify-between text-sm">
|
||||
<span className="font-medium">本次增量</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{topupGb} GB</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[topupGb]}
|
||||
onValueChange={(values: number | readonly number[]) => {
|
||||
const value = Array.isArray(values) ? values[0] : values;
|
||||
setTopupGb(value ?? min);
|
||||
}}
|
||||
min={min}
|
||||
max={Math.max(min, max)}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{isFixedAmount
|
||||
? `固定金额 ¥${(config.topupFixedPrice ?? 0).toFixed(2)}`
|
||||
: `按 ¥${(config.topupPricePerGb ?? 0).toFixed(2)}/GB 计费`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">本次应付</span>
|
||||
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleTopup}
|
||||
disabled={loading || disabled}
|
||||
>
|
||||
{loading ? "创建中..." : "去支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user