Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}