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,145 @@
"use client";
import { startTransition, useEffect, useState } from "react";
import { Activity } from "lucide-react";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchJson } from "@/lib/fetch-json";
import { cn } from "@/lib/utils";
import { getCarrierLabel } from "./proxy-signal-grid";
type Range = "1d" | "7d" | "30d";
interface HistoryData {
carriers: string[];
points: Record<string, string | number>[];
sufficient: boolean;
}
const RANGE_LABELS: Record<Range, string> = { "1d": "1 天", "7d": "7 天", "30d": "30 天" };
const CARRIER_COLORS: Record<string, string> = {
telecom: "oklch(0.55 0.15 250)",
unicom: "oklch(0.55 0.15 145)",
mobile: "oklch(0.55 0.15 30)",
};
export function LatencyDetailDialog({
nodeId,
open,
onOpenChange,
}: {
nodeId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [range, setRange] = useState<Range>("1d");
const [data, setData] = useState<HistoryData | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !nodeId) return;
startTransition(() => {
setLoading(true);
});
fetchJson<HistoryData>(`/api/latency/history?nodeId=${nodeId}&range=${range}`)
.then(setData)
.catch(() => setData(null))
.finally(() => {
startTransition(() => {
setLoading(false);
});
});
}, [open, nodeId, range]);
const formatTime = (iso: string) => {
const d = new Date(iso);
if (range === "1d") return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
if (range === "7d") return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:00`;
return `${d.getMonth() + 1}/${d.getDate()}`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="inline-flex items-center gap-2">
<Activity className="size-4 text-primary" />
</DialogTitle>
</DialogHeader>
<div className="flex gap-1.5 mb-4">
{(Object.keys(RANGE_LABELS) as Range[]).map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold transition-colors",
range === r
? "bg-primary text-primary-foreground"
: "bg-muted/40 text-muted-foreground hover:bg-muted/70",
)}
>
{RANGE_LABELS[r]}
</button>
))}
</div>
<div className="h-[420px]">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">...</div>
) : !data || data.points.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 text-sm text-muted-foreground">
<Activity className="size-5" />
<p></p>
</div>
) : !data.sufficient && range !== "1d" ? (
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-amber-500/30 bg-amber-500/5 text-sm text-muted-foreground">
<Activity className="size-5 text-amber-500" />
<p> {RANGE_LABELS[range]}</p>
<p className="text-xs"></p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.points}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tickFormatter={formatTime} tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis unit=" ms" width={55} tick={{ fontSize: 11 }} />
<Tooltip
labelFormatter={(label) => formatTime(String(label))}
formatter={(value, name) => [`${Number(value)} ms`, getCarrierLabel(String(name))]}
/>
<Legend formatter={getCarrierLabel} />
{data.carriers.map((c) => (
<Line
key={c}
type="monotone"
dataKey={c}
stroke={CARRIER_COLORS[c] ?? "oklch(0.5 0.1 0)"}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useEffect, useEffectEvent, useSyncExternalStore } from "react";
import { fetchJson } from "@/lib/fetch-json";
export interface LatencyItem {
carrier: string;
latencyMs: number;
}
export interface LatencyRefreshMeta {
loading: boolean;
updatedAt: string | null;
nextRefreshAt: string | null;
error: string | null;
}
type LatencyMap = Record<string, LatencyItem[]>;
const REFRESH_INTERVAL_MS = 60 * 1000;
let latencyData: LatencyMap = {};
let latencyMeta: LatencyRefreshMeta = {
loading: false,
updatedAt: null,
nextRefreshAt: null,
error: null,
};
const listeners = new Set<() => void>();
function getSnapshot(): LatencyMap {
return latencyData;
}
function getMetaSnapshot(): LatencyRefreshMeta {
return latencyMeta;
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
function emit() {
for (const cb of listeners) cb();
}
function setLatencyData(data: LatencyMap) {
latencyData = data;
emit();
}
function setLatencyMeta(meta: Partial<LatencyRefreshMeta>) {
latencyMeta = { ...latencyMeta, ...meta };
emit();
}
export function useLatency(nodeId: string | null): LatencyItem[] {
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nodeId ? data[nodeId] ?? [] : [];
}
export function useLatencyRefreshMeta(): LatencyRefreshMeta {
return useSyncExternalStore(subscribe, getMetaSnapshot, getMetaSnapshot);
}
export function LatencyLoader({ nodeIds }: { nodeIds: string[] }) {
const nodeIdKey = nodeIds.join(",");
const load = useEffectEvent(async () => {
if (!nodeIdKey) return;
setLatencyMeta({ loading: true, error: null });
try {
const result = await fetchJson<LatencyMap>(`/api/latency?nodeIds=${nodeIdKey}`);
const now = Date.now();
setLatencyData(result);
setLatencyMeta({
loading: false,
updatedAt: new Date(now).toISOString(),
nextRefreshAt: new Date(now + REFRESH_INTERVAL_MS).toISOString(),
error: null,
});
} catch {
setLatencyMeta({
loading: false,
error: "线路体验暂时无法刷新,稍后会自动重试。",
});
}
});
useEffect(() => {
void load();
const timer = window.setInterval(() => void load(), REFRESH_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [nodeIdKey]);
return null;
}

View File

@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { Film, LifeBuoy, Radio } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { EmptyState, PageShell } from "@/components/shared/page-shell";
import { buttonVariants } from "@/components/ui/button";
import { PendingOrderBanner } from "./pending-order-banner";
import { ProxyPlanCard } from "./proxy-plan-card";
import { StreamingPlanCard } from "./streaming-plan-card";
import { StorePlanSection } from "./store-plan-section";
import { StoreLatencyRecommendations } from "./store-latency-recommendations";
import { LatencyLoader } from "./latency-loader";
import { TraceLoader } from "./trace-loader";
import { getStorePageData } from "./store-data";
import {
getProxyNodeIds,
getProxyPlans,
getStreamingPlans,
toProxyPlanCard,
toStreamingPlanCard,
} from "./store-plan-mappers";
import { sortPlansForDisplay } from "./store-recommendations";
export const metadata: Metadata = {
title: "套餐商店",
description: "浏览代理与流媒体套餐,查看实时可用性并下单。",
};
export default async function StorePage() {
const session = await getServerSession(authOptions);
const { plans, availabilityMap, pendingOrder, latencyRecommendations } = await getStorePageData(session?.user.id);
const proxyPlans = getProxyPlans(plans);
const streamingPlans = getStreamingPlans(plans);
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
const streamingCards = sortPlansForDisplay(streamingPlans.map((plan) => toStreamingPlanCard(plan, availabilityMap.get(plan.id))));
const proxyNodeIds = getProxyNodeIds(proxyPlans);
return (
<PageShell>
<section className="space-y-5">
<div className="space-y-3">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
</p>
<h1 className="text-display text-2xl font-semibold sm:text-3xl">
</h1>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="choice-card p-4">
<div className="flex items-start gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Radio className="size-4" />
</span>
<div>
<p className="text-sm font-medium"></p>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
</div>
</div>
<div className="choice-card p-4">
<div className="flex items-start gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
<Film className="size-4" />
</span>
<div>
<p className="text-sm font-medium"></p>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
</div>
</div>
</div>
</section>
<PendingOrderBanner order={pendingOrder} />
{proxyCards.length > 0 && (
<StoreLatencyRecommendations initialItems={latencyRecommendations} />
)}
{proxyCards.length > 0 && (
<StorePlanSection
id="proxy-plans"
eyebrow="PROXY"
title="代理连接"
gridClassName="lg:grid-cols-2 xl:grid-cols-3"
after={(
<>
{proxyNodeIds.length > 0 && (
<>
<LatencyLoader nodeIds={proxyNodeIds} />
<TraceLoader nodeIds={proxyNodeIds} />
</>
)}
</>
)}
>
{proxyCards.map((plan) => (
<ProxyPlanCard key={plan.id} plan={plan} />
))}
</StorePlanSection>
)}
{streamingCards.length > 0 && (
<StorePlanSection
id="streaming-plans"
eyebrow="STREAMING"
title="流媒体共享"
>
{streamingCards.map((plan) => (
<StreamingPlanCard key={plan.id} plan={plan} />
))}
</StorePlanSection>
)}
{plans.length === 0 && (
<EmptyState
eyebrow="商店准备中"
icon={<LifeBuoy className="size-5" />}
title="新的订阅正在准备"
description="可购买的套餐会在这里出现。如果你希望提前了解补货时间,可以联系支持团队。"
action={
<Link href="/support" className={buttonVariants()}>
</Link>
}
/>
)}
</PageShell>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import Link from "next/link";
import { Clock3, ShoppingBag } from "lucide-react";
import { cancelOwnPendingOrder } from "@/actions/user/orders";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { buttonVariants } from "@/components/ui/button";
interface PendingStoreOrder {
id: string;
amount: number;
planName: string;
createdAt: string;
}
export function PendingOrderBanner({ order }: { order: PendingStoreOrder | null }) {
if (!order) return null;
return (
<section className="surface-card overflow-hidden rounded-xl p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
<Clock3 className="size-4" />
</span>
<div>
<p className="text-sm font-semibold"></p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{order.planName} · ¥{order.amount.toFixed(2)}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Link href={`/pay/${order.id}`} className={buttonVariants({ size: "lg" })}>
<ShoppingBag className="size-4" />
</Link>
<ConfirmActionButton
variant="outline"
size="lg"
title="取消这笔订单?"
description="取消后会释放本次占用的名额,你可以重新选择套餐或支付方式。"
confirmLabel="取消订单"
successMessage="订单已取消"
errorMessage="取消订单失败"
onConfirm={() => cancelOwnPendingOrder(order.id)}
>
</ConfirmActionButton>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,49 @@
import { StatusBadge } from "@/components/shared/status-badge";
interface PlanAvailabilityBadgesProps {
totalLimit: number | null;
perUserLimit: number | null;
remainingCount: number | null;
inboundCount?: number;
hasInboundOptions?: boolean;
isAvailable: boolean;
unavailableLabel: string;
missingInboundLabel?: string;
}
export function PlanAvailabilityBadges({
totalLimit,
perUserLimit,
remainingCount,
inboundCount,
hasInboundOptions,
isAvailable,
unavailableLabel,
missingInboundLabel,
}: PlanAvailabilityBadgesProps) {
return (
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="neutral" className="text-xs">
{totalLimit == null ? "不限量" : `剩余 ${remainingCount ?? 0}`}
</StatusBadge>
<StatusBadge tone="neutral" className="text-xs">
{perUserLimit == null ? "不限购" : `限购 ${perUserLimit}`}
</StatusBadge>
{inboundCount != null && inboundCount > 0 && (
<StatusBadge tone="info" className="text-xs">
{inboundCount}
</StatusBadge>
)}
{hasInboundOptions === false && missingInboundLabel && (
<StatusBadge tone="danger" className="text-xs">
{missingInboundLabel}
</StatusBadge>
)}
{!isAvailable && (
<StatusBadge tone="danger" className="text-xs">
{unavailableLabel}
</StatusBadge>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
interface StorePlanHeaderProps {
name: string;
meta?: string | null;
price: string;
priceSuffix: string;
eyebrow?: ReactNode;
}
export function StorePlanHeader({ name, meta, price, priceSuffix, eyebrow = "PROXY" }: StorePlanHeaderProps) {
return (
<div className="px-5 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1.5">
<div className="inline-flex rounded-md border border-primary/15 bg-primary/10 px-2 py-0.5 text-xs font-medium tracking-wide text-primary">
{eyebrow}
</div>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em] text-balance">{name}</h3>
{meta && <p className="mt-0.5 text-sm text-muted-foreground">{meta}</p>}
</div>
</div>
<div className="shrink-0 text-right">
<p className="text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums">{price}</p>
<p className="text-xs text-muted-foreground">{priceSuffix}</p>
</div>
</div>
</div>
);
}
export function StorePlanDescription({ description }: { description: string | null }) {
if (!description) return null;
return (
<div className="px-5 pb-3">
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm leading-6 text-muted-foreground text-pretty [&_a]:text-primary [&_a]:underline [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-medium [&_li]:my-0 [&_ol]:my-1 [&_p]:my-1 [&_ul]:my-1">
<ReactMarkdown>{description}</ReactMarkdown>
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Clock3, Network, Server, ShoppingCart } from "lucide-react";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { purchaseProxy } from "@/actions/user/purchase";
import { addProxyPlanToCart } from "@/actions/user/cart";
import { StorePlanDescription } from "./plan-card-parts";
import { ProxySignalPanel } from "./proxy-signal-grid";
import { useLatency } from "./latency-loader";
import { useTraces, type TraceItem } from "./trace-loader";
import {
ProxyAvailabilityNotice,
ProxyInboundSelect,
ProxyPurchaseSummary,
ProxyTrafficSlider,
} from "./proxy-purchase-fields";
import { usePlanAvailabilityCheck } from "./use-plan-availability-check";
import { ProxyTraceDetailDialog } from "./proxy-trace-detail-dialog";
import { LatencyDetailDialog } from "./latency-detail-dialog";
import type { ProxyPlan } from "./proxy-plan-types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
plan: ProxyPlan;
}
export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
const fixedTrafficGb = plan.fixedTrafficGb ?? plan.minTrafficGb;
const [trafficGb, setTrafficGb] = useState(
plan.pricingMode === "FIXED_PACKAGE" ? fixedTrafficGb : plan.minTrafficGb,
);
const [selectedInboundId, setSelectedInboundId] = useState(plan.inboundOptions[0]?.id ?? "");
const [loading, setLoading] = useState(false);
const [cartLoading, setCartLoading] = useState(false);
const [selectedTrace, setSelectedTrace] = useState<TraceItem | null>(null);
const [latencyDialogOpen, setLatencyDialogOpen] = useState(false);
const router = useRouter();
const hasInboundOptions = plan.inboundOptions.length > 0;
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
const { checking, checkAvailability } = usePlanAvailabilityCheck(plan.id);
const latencyItems = useLatency(plan.nodeId);
const traceItems = useTraces(plan.nodeId);
const totalPrice = useMemo(
() => (isFixedPackage ? (plan.fixedPrice ?? 0) : trafficGb * plan.pricePerGb).toFixed(2),
[isFixedPackage, plan.fixedPrice, plan.pricePerGb, trafficGb],
);
async function handlePurchase() {
if (!selectedInboundId) {
toast.error("请先选择一个线路入口");
return;
}
setLoading(true);
try {
const orderId = await purchaseProxy(plan.id, trafficGb, selectedInboundId);
router.push(`/pay/${orderId}`);
} catch (error) {
toast.error(getErrorMessage(error, "下单失败"));
} finally {
setLoading(false);
}
}
async function handleAddToCart() {
if (!selectedInboundId) {
toast.error("请先选择一个线路入口");
return;
}
setCartLoading(true);
try {
await addProxyPlanToCart(plan.id, trafficGb, selectedInboundId);
toast.success("已加入购物车");
onOpenChange(false);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "加入购物车失败"));
} finally {
setCartLoading(false);
}
}
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-primary">
<Network className="size-3.5" /> PROXY
</div>
<DialogTitle>{plan.name}</DialogTitle>
<DialogDescription>
{plan.nodeName} · {plan.durationDays} · ¥{displayPrice}{isFixedPackage ? "/套餐" : "/GB"}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto -mx-6 px-6 space-y-4">
{/* Compact info row — above the grid so both columns align */}
<div className="flex flex-wrap gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5">
<Server className="size-3.5 text-primary" />
{plan.nodeName}
</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5">
<Clock3 className="size-3.5 text-primary" />
{plan.durationDays}
</span>
</div>
<div className="grid items-start gap-6 lg:grid-cols-[1fr_20rem]">
{/* Left: purchase config — always visible without scrolling */}
<div className="space-y-3">
<ProxyInboundSelect
inbounds={plan.inboundOptions}
value={selectedInboundId}
onValueChange={setSelectedInboundId}
disabled={!hasInboundOptions}
/>
{isFixedPackage ? (
<div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2.5 text-sm">
<span className="font-semibold text-primary"></span>
<span className="ml-2 text-muted-foreground"> {fixedTrafficGb} GB</span>
</div>
) : (
<ProxyTrafficSlider
value={trafficGb}
min={plan.minTrafficGb}
max={plan.maxTrafficGb}
onChange={setTrafficGb}
/>
)}
<ProxyPurchaseSummary totalPrice={totalPrice} />
{!plan.isAvailable && (
<ProxyAvailabilityNotice nextAvailableAt={plan.nextAvailableAt} />
)}
<div className="flex gap-2">
<Button
size="lg"
variant="outline"
className="flex-1"
onClick={handleAddToCart}
disabled={cartLoading || !plan.isAvailable || !hasInboundOptions}
>
<ShoppingCart className="size-4" />
{cartLoading ? "正在加入..." : "加入购物车"}
</Button>
<Button
size="lg"
className="flex-1"
onClick={handlePurchase}
disabled={loading || !plan.isAvailable || !hasInboundOptions}
>
{loading ? "正在保留..." : "立即支付"}
{plan.isAvailable && <ArrowRight className="size-4" />}
</Button>
</div>
{!plan.isAvailable && (
<Button variant="outline" size="lg" className="w-full" onClick={checkAvailability} disabled={checking}>
{checking ? "查询中..." : "查看补位时间"}
</Button>
)}
{/* Description inline below actions */}
{plan.description && (
<div className="border-t border-border pt-3">
<p className="mb-1.5 text-xs font-medium text-muted-foreground"></p>
<StorePlanDescription description={plan.description} />
</div>
)}
</div>
{/* Right: signal data — supplementary, scrolls independently on desktop */}
<div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3">
<ProxySignalPanel
latencyItems={latencyItems}
traceItems={traceItems}
onTraceSelect={setSelectedTrace}
onLatencyClick={() => setLatencyDialogOpen(true)}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<ProxyTraceDetailDialog
trace={selectedTrace}
onOpenChange={(open) => {
if (!open) setSelectedTrace(null);
}}
/>
<LatencyDetailDialog
nodeId={plan.nodeId}
open={latencyDialogOpen}
onOpenChange={setLatencyDialogOpen}
/>
</>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Gauge, Network, Search, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StorePlanHeader } from "./plan-card-parts";
import { PlanAvailabilityBadges } from "./plan-availability-badges";
import { ProxyDetailDialog } from "./proxy-detail-dialog";
import type { ProxyPlan } from "./proxy-plan-types";
interface Props {
plan: ProxyPlan;
}
export function ProxyPlanCard({ plan }: Props) {
const [dialogOpen, setDialogOpen] = useState(false);
const hasInboundOptions = plan.inboundOptions.length > 0;
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
return (
<>
<article
id={`plan-${plan.id}`}
className="surface-card surface-lift group relative scroll-mt-24 flex flex-col overflow-hidden rounded-xl text-left"
>
<StorePlanHeader
eyebrow={
<span className="inline-flex items-center gap-2">
<Network className="size-3.5" /> PROXY
</span>
}
name={plan.name}
meta={`${plan.nodeName} · ${plan.durationDays}`}
price={`¥${displayPrice}`}
priceSuffix={isFixedPackage ? "/套餐" : "/GB"}
/>
<div className="relative px-6">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<p className="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-sm font-medium">
{isFixedPackage ? `${plan.fixedTrafficGb ?? plan.minTrafficGb} GB 固定` : `${plan.minTrafficGb}-${plan.maxTrafficGb} GB`}
</p>
</div>
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Sparkles className="size-3.5 text-primary" /> 线
</p>
<p className="mt-1 text-sm font-medium">{hasInboundOptions ? `${plan.inboundOptions.length} 条可选` : "整理中"}</p>
</div>
</div>
</div>
<div className="mt-auto space-y-4 px-6 pb-6 pt-4">
<PlanAvailabilityBadges
totalLimit={plan.totalLimit}
perUserLimit={plan.perUserLimit}
remainingCount={plan.remainingCount}
inboundCount={plan.inboundOptions.length}
hasInboundOptions={hasInboundOptions}
isAvailable={plan.isAvailable}
missingInboundLabel="线路整理中"
unavailableLabel="暂时售罄"
/>
<Button
type="button"
className="w-full"
size="lg"
onClick={() => setDialogOpen(true)}
>
<Search className="size-4" />
</Button>
</div>
</article>
<ProxyDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} />
</>
);
}

View File

@@ -0,0 +1,31 @@
export interface ProxyInboundOption {
id: string;
protocol: "VMESS" | "VLESS" | "TROJAN" | "SHADOWSOCKS" | "HYSTERIA2";
port: number;
tag: string;
displayName: string;
}
export interface ProxyPlan {
id: string;
name: string;
description: string | null;
durationDays: number;
sortOrder: number;
pricingMode: "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
pricePerGb: number;
fixedTrafficGb: number | null;
fixedPrice: number | null;
minTrafficGb: number;
maxTrafficGb: number;
nodeId: string | null;
nodeName: string;
inboundOptions: ProxyInboundOption[];
totalLimit: number | null;
perUserLimit: number | null;
activeCount: number;
remainingCount: number | null;
remainingByUserLimit: number | null;
isAvailable: boolean;
nextAvailableAt: string | null;
}

View File

@@ -0,0 +1,147 @@
"use client";
import { AlertCircle, Gauge, Router, WalletCards } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import type { ProxyInboundOption } from "./proxy-plan-types";
interface ProxyInboundSelectProps {
inbounds: ProxyInboundOption[];
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}
export function ProxyInboundSelect({
inbounds,
value,
onValueChange,
disabled,
}: ProxyInboundSelectProps) {
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Router className="size-4" />
</div>
<div>
<p className="text-sm font-semibold">线</p>
<p className="text-xs text-muted-foreground">使</p>
</div>
</div>
<Select
value={value}
onValueChange={(nextValue) => onValueChange(nextValue ?? "")}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择一个线路入口">
{(selectedValue) => {
const match = inbounds.find((item) => item.id === selectedValue);
return match ? formatInboundOption(match) : "选择一个线路入口";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{inbounds.map((inbound) => (
<SelectItem key={inbound.id} value={inbound.id}>
{formatInboundOption(inbound)}
</SelectItem>
))}
</SelectContent>
</Select>
{disabled && (
<p className="flex items-center gap-2 text-xs font-medium text-destructive">
<AlertCircle className="size-3.5" />
线
</p>
)}
</div>
);
}
interface ProxyTrafficSliderProps {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}
export function ProxyTrafficSlider({ value, min, max, onChange }: ProxyTrafficSliderProps) {
const percent = max > min ? Math.round(((value - min) / (max - min)) * 100) : 0;
return (
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Gauge className="size-4" />
</div>
<div>
<p className="text-sm font-semibold"></p>
<p className="text-xs text-muted-foreground">使</p>
</div>
</div>
<div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2 text-right text-primary">
<p className="text-2xl font-semibold tracking-[-0.05em] tabular-nums">{value}</p>
<p className="text-[0.68rem] font-semibold tracking-[0.12em]">GB</p>
</div>
</div>
<Slider
value={[value]}
onValueChange={(values: number | readonly number[]) => {
const nextValue = Array.isArray(values) ? values[0] : values;
onChange(nextValue ?? min);
}}
min={min}
max={max}
step={max <= 100 ? 1 : 10}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{min} GB</span>
<span className="rounded-full bg-muted px-2 py-1"> {percent}%</span>
<span>{max} GB</span>
</div>
</div>
);
}
export function ProxyPurchaseSummary({ totalPrice }: { totalPrice: string }) {
return (
<div className="rounded-lg border border-primary/15 bg-primary/10 p-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-[var(--shadow-button)]">
<WalletCards className="size-4" />
</div>
<div>
<p className="text-sm font-semibold"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
</div>
</div>
);
}
export function ProxyAvailabilityNotice({ nextAvailableAt }: { nextAvailableAt: string | null }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-destructive/15 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<p>
{nextAvailableAt ? `,预计 ${nextAvailableAt} 后有机会补位` : ""}
</p>
</div>
);
}
function formatInboundOption(inbound: ProxyInboundOption) {
return inbound.displayName || inbound.tag || "优选线路入口";
}

View File

@@ -0,0 +1,163 @@
"use client";
import { Activity, Clock3, RefreshCw, Route } from "lucide-react";
import { useLatencyRefreshMeta, type LatencyItem } from "./latency-loader";
import type { TraceItem } from "./trace-loader";
import { cn } from "@/lib/utils";
const carrierLabels: Record<string, string> = {
telecom: "电信",
unicom: "联通",
mobile: "移动",
};
const CARRIER_ORDER: string[] = ["telecom", "unicom", "mobile"];
function sortByCarrier<T extends { carrier: string }>(items: T[]): T[] {
return [...items].sort(
(a, b) => (CARRIER_ORDER.indexOf(a.carrier) >>> 0) - (CARRIER_ORDER.indexOf(b.carrier) >>> 0),
);
}
export function getCarrierLabel(carrier: string) {
return carrierLabels[carrier] ?? carrier.replace("中国", "");
}
function formatRefreshLabel(meta: ReturnType<typeof useLatencyRefreshMeta>) {
if (meta.loading) return "正在刷新";
if (!meta.updatedAt) return "约 1 分钟更新";
const updatedAt = new Date(meta.updatedAt);
const nextRefreshAt = meta.nextRefreshAt ? new Date(meta.nextRefreshAt) : null;
const updated = updatedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
const next = nextRefreshAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
return next ? `${updated} 更新 · ${next} 再刷` : `${updated} 已更新`;
}
export function ProxySignalPanel({
latencyItems,
traceItems,
onTraceSelect,
onLatencyClick,
}: {
latencyItems: LatencyItem[];
traceItems: TraceItem[];
onTraceSelect: (item: TraceItem) => void;
onLatencyClick?: () => void;
}) {
const refreshMeta = useLatencyRefreshMeta();
if (latencyItems.length === 0 && traceItems.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
线
</div>
);
}
return (
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="inline-flex items-center gap-2 text-sm font-semibold">
<Activity className="size-4 text-primary" /> 线
</p>
<p className="mt-1 text-xs text-muted-foreground">访线</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-1.5 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold text-primary">
{refreshMeta.loading ? <RefreshCw className="size-3 animate-spin" /> : <Clock3 className="size-3" />}
{formatRefreshLabel(refreshMeta)}
</div>
{refreshMeta.error && (
<div className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold text-amber-700 dark:text-amber-300">
</div>
)}
</div>
</div>
{latencyItems.length > 0 && <ProxyLatencyGrid items={latencyItems} onClick={onLatencyClick} />}
{traceItems.length > 0 && <ProxyTraceGrid items={traceItems} onTraceSelect={onTraceSelect} />}
</div>
);
}
export function ProxyLatencyGrid({ items, onClick }: { items: LatencyItem[]; onClick?: () => void }) {
if (items.length === 0) return null;
const sorted = sortByCarrier(items);
const bestLatency = Math.min(...sorted.map((item) => item.latencyMs));
return (
<div className="space-y-2">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Activity className="size-3.5" /> {onClick && <span className="font-normal">· </span>}
</p>
<div className={cn("grid grid-cols-3 gap-2", onClick && "cursor-pointer")} onClick={onClick}>
{sorted.map((item) => {
const strong = item.latencyMs === bestLatency;
return (
<div
key={item.carrier}
className={cn(
"rounded-lg border px-3 py-3 text-center transition-colors duration-200",
strong ? "border-primary/20 bg-primary/10 text-primary" : "border-border bg-background",
onClick && "hover:border-primary/25 hover:bg-primary/7",
)}
>
<p className="text-[11px] font-semibold leading-tight text-muted-foreground">
{getCarrierLabel(item.carrier)}
</p>
<p className="mt-1 text-xl font-semibold tracking-tight tabular-nums">
{item.latencyMs}
<span className="ml-0.5 text-xs font-normal text-muted-foreground">ms</span>
</p>
{strong && <p className="mt-1 text-[10px] font-semibold tracking-[0.14em]">BEST</p>}
</div>
);
})}
</div>
</div>
);
}
export function ProxyTraceGrid({
items,
onTraceSelect,
}: {
items: TraceItem[];
onTraceSelect: (item: TraceItem) => void;
}) {
if (items.length === 0) return null;
const sorted = sortByCarrier(items);
return (
<div className="space-y-2">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Route className="size-3.5" /> 访
</p>
<div className="grid gap-2 sm:grid-cols-3">
{sorted.map((item) => (
<button
key={item.carrier}
type="button"
className="group rounded-lg border border-border bg-background px-3 py-3 text-left transition-colors duration-200 hover:border-primary/25 hover:bg-primary/7 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20"
onClick={(event) => {
event.stopPropagation();
onTraceSelect(item);
}}
>
<p className="text-[11px] font-semibold text-muted-foreground">
{getCarrierLabel(item.carrier)} · {item.hopCount}
</p>
<p className="mt-1 truncate text-xs font-semibold tracking-tight group-hover:text-primary">
{item.summary}
</p>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getCarrierLabel } from "./proxy-signal-grid";
import type { TraceItem } from "./trace-loader";
interface ProxyTraceDetailDialogProps {
trace: TraceItem | null;
onOpenChange: (open: boolean) => void;
}
export function ProxyTraceDetailDialog({ trace, onOpenChange }: ProxyTraceDetailDialogProps) {
return (
<Dialog open={trace !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{trace ? `${getCarrierLabel(trace.carrier)} 路由 — ${trace.summary}` : "路由详情"}
</DialogTitle>
</DialogHeader>
{trace && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground mb-3">
{trace.hopCount}
{trace.updatedAt && ` · 更新于 ${new Date(trace.updatedAt).toLocaleString("zh-CN")}`}
</p>
<div className="space-y-0.5">
{trace.hops.map((hop) => (
<div
key={`${hop.hop}-${hop.ip}`}
className="flex items-baseline gap-2 text-xs py-1 px-2 rounded hover:bg-muted/40"
>
<span className="w-6 text-right text-muted-foreground shrink-0">
{hop.hop}
</span>
<span className="font-mono min-w-0 truncate">{hop.ip || "*"}</span>
<span className="text-muted-foreground truncate">{hop.geo}</span>
{hop.latency > 0 && (
<span className="ml-auto shrink-0 text-muted-foreground">
{hop.latency.toFixed(1)}ms
</span>
)}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import { prisma } from "@/lib/prisma";
import { normalizeTraceText } from "@/lib/trace-normalize";
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
import { getLatencyRecommendations } from "@/services/latency-recommendations";
export async function getStorePageData(userId?: string) {
const [plans, pendingOrder, latencyRecommendations] = await Promise.all([
prisma.subscriptionPlan.findMany({
where: { isActive: true },
include: {
node: true,
inbound: true,
streamingService: true,
inboundOptions: {
include: { inbound: true },
orderBy: { createdAt: "asc" },
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}),
userId
? prisma.order.findFirst({
where: { userId, status: "PENDING" },
include: { plan: { select: { name: true } } },
orderBy: { createdAt: "desc" },
})
: null,
getLatencyRecommendations(),
]);
const availabilityMap = new Map<string, PlanAvailability>();
await Promise.all(
plans.map(async (plan) => {
const availability = await getPlanAvailability(plan, { userId });
availabilityMap.set(plan.id, availability);
}),
);
return {
plans,
availabilityMap,
latencyRecommendations,
pendingOrder: pendingOrder
? {
id: pendingOrder.id,
amount: Number(pendingOrder.amount),
planName: normalizeTraceText(pendingOrder.plan.name),
createdAt: pendingOrder.createdAt.toISOString(),
}
: null,
};
}

View File

@@ -0,0 +1,139 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Activity, Clock3, RadioTower, RefreshCw, Sparkles } from "lucide-react";
import { fetchJson } from "@/lib/fetch-json";
import { cn } from "@/lib/utils";
import {
RECOMMENDATION_CARRIERS,
carrierLabels,
type LatencyRecommendation,
} from "@/services/latency-recommendation-types";
interface RecommendationPayload {
items: LatencyRecommendation[];
updatedAt: string;
refreshIntervalMs: number;
}
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
function formatTime(value: string | null) {
if (!value) return "等待刷新";
return new Date(value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
function getLatencyTone(latencyMs?: number) {
if (latencyMs == null) return "border-dashed bg-muted/20 text-muted-foreground";
if (latencyMs <= 80) return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
if (latencyMs <= 150) return "border-primary/20 bg-primary/10 text-primary";
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
}
export function StoreLatencyRecommendations({
initialItems,
}: {
initialItems: LatencyRecommendation[];
}) {
const [items, setItems] = useState(initialItems);
const [updatedAt, setUpdatedAt] = useState<string | null>(initialItems[0]?.checkedAt ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const payload = await fetchJson<RecommendationPayload>("/api/latency/recommendations");
if (cancelled) return;
setItems(payload.items);
setUpdatedAt(payload.updatedAt);
setError(null);
} catch {
if (!cancelled) setError("推荐线路暂时无法刷新");
} finally {
if (!cancelled) setLoading(false);
}
}
const timer = window.setInterval(() => void load(), REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
const itemMap = new Map(items.map((item) => [item.carrier, item]));
return (
<section className="surface-card overflow-hidden rounded-2xl p-5 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl 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>
<h2 className="text-xl font-semibold tracking-[-0.04em] sm:text-2xl"></h2>
<p className="text-sm leading-6 text-muted-foreground text-pretty">
线 5
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-3 py-1">
{loading ? <RefreshCw className="size-3.5 animate-spin" /> : <Clock3 className="size-3.5" />}
{formatTime(updatedAt)}
</span>
{error && <span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-3 py-1 text-amber-700 dark:text-amber-300"></span>}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{RECOMMENDATION_CARRIERS.map((carrier) => {
const item = itemMap.get(carrier);
return (
<div
key={carrier}
className={cn(
"rounded-xl border p-4 transition-colors duration-200",
getLatencyTone(item?.latencyMs),
)}
>
<div className="flex items-center justify-between gap-3">
<p className="inline-flex items-center gap-2 text-sm font-semibold">
<RadioTower className="size-4" /> {carrierLabels[carrier]}
</p>
{item && (
<span className="rounded-full bg-background/70 px-2.5 py-1 text-xs font-semibold tabular-nums">
{item.latencyMs} ms
</span>
)}
</div>
{item ? (
<div className="mt-4 space-y-3">
<div>
<p className="text-lg font-semibold tracking-[-0.04em] text-foreground">{item.nodeName}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{item.planName}</p>
</div>
<Link
href={`#plan-${item.planId}`}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-primary hover:underline"
>
<Activity className="size-3.5" />
</Link>
</div>
) : (
<p className="mt-4 text-sm leading-6 text-muted-foreground"></p>
)}
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,137 @@
import type { Prisma } from "@prisma/client";
import {
formatAvailabilityDateTime,
type PlanAvailability,
} from "@/services/plan-availability";
import { normalizeTraceText } from "@/lib/trace-normalize";
import type { ProxyPlan } from "./proxy-plan-types";
import type { StreamingPlan } from "./streaming-plan-types";
export type StorePlanRecord = Prisma.SubscriptionPlanGetPayload<{
include: {
node: true;
inbound: true;
streamingService: true;
inboundOptions: {
include: { inbound: true };
};
};
}>;
export function getProxyPlans(plans: StorePlanRecord[]) {
return plans.filter((plan) => plan.type === "PROXY");
}
export function getStreamingPlans(plans: StorePlanRecord[]) {
return plans.filter((plan) => plan.type === "STREAMING");
}
export function getProxyNodeIds(plans: StorePlanRecord[]) {
const ids = new Set<string>();
for (const plan of plans) {
if (plan.nodeId) ids.add(plan.nodeId);
}
return [...ids];
}
export function toProxyPlanCard(plan: StorePlanRecord, availability?: PlanAvailability): ProxyPlan {
const inboundOptions = getAvailableInboundOptions(plan);
return {
id: plan.id,
name: normalizeTraceText(plan.name),
description: plan.description ? normalizeTraceText(plan.description) : null,
sortOrder: plan.sortOrder,
durationDays: plan.durationDays,
pricingMode: plan.pricingMode,
pricePerGb: Number(plan.pricePerGb ?? 0),
fixedTrafficGb: plan.fixedTrafficGb,
fixedPrice: plan.fixedPrice == null ? null : Number(plan.fixedPrice),
minTrafficGb: plan.pricingMode === "FIXED_PACKAGE" ? (plan.fixedTrafficGb ?? plan.minTrafficGb ?? 10) : (plan.minTrafficGb ?? 10),
maxTrafficGb: plan.pricingMode === "FIXED_PACKAGE" ? (plan.fixedTrafficGb ?? plan.maxTrafficGb ?? 1000) : (plan.maxTrafficGb ?? 1000),
nodeId: plan.nodeId,
nodeName: plan.node?.name ? normalizeTraceText(plan.node.name) : "未知节点",
inboundOptions: inboundOptions.map((inbound) => ({
id: inbound.id,
protocol: inbound.protocol,
port: inbound.port,
tag: normalizeTraceText(inbound.tag),
displayName: getInboundDisplayName(inbound),
})),
totalLimit: plan.totalLimit,
perUserLimit: plan.perUserLimit,
activeCount: availability?.activeCount ?? 0,
remainingCount: availability?.remainingByPlanLimit ?? null,
remainingByUserLimit: availability?.remainingByUserLimit ?? null,
isAvailable: availability?.available ?? true,
nextAvailableAt: formatNextAvailableAt(availability),
};
}
export function toStreamingPlanCard(
plan: StorePlanRecord,
availability?: PlanAvailability,
): StreamingPlan {
return {
id: plan.id,
name: normalizeTraceText(plan.name),
description: plan.description ? normalizeTraceText(plan.description) : null,
sortOrder: plan.sortOrder,
durationDays: plan.durationDays,
price: Number(plan.price),
serviceName: plan.streamingService?.name ? normalizeTraceText(plan.streamingService.name) : null,
totalLimit: plan.totalLimit,
perUserLimit: plan.perUserLimit,
activeCount: availability?.activeCount ?? 0,
remainingCount: getStreamingRemainingCount(availability),
remainingByUserLimit: availability?.remainingByUserLimit ?? null,
isAvailable: availability?.available ?? true,
nextAvailableAt: formatNextAvailableAt(availability),
};
}
function getAvailableInboundOptions(plan: StorePlanRecord) {
if (plan.inboundOptions.length > 0) {
return plan.inboundOptions
.map((option) => option.inbound)
.filter((inbound) => inbound.isActive && inbound.serverId === plan.nodeId);
}
if (plan.inbound && plan.inbound.isActive && plan.inbound.serverId === plan.nodeId) {
return [plan.inbound];
}
return [];
}
function getStreamingRemainingCount(availability?: PlanAvailability) {
if (!availability) return null;
if (
availability.remainingByPlanLimit == null &&
availability.remainingByServiceCapacity != null
) {
return availability.remainingByServiceCapacity;
}
return availability.remainingByPlanLimit ?? null;
}
function formatNextAvailableAt(availability?: PlanAvailability) {
return availability?.nextAvailableAt
? formatAvailabilityDateTime(availability.nextAvailableAt)
: null;
}
function getInboundDisplayName(inbound: { tag: string; settings: unknown }) {
const settings = inbound.settings;
if (settings && typeof settings === "object" && "displayName" in settings) {
const value = (settings as { displayName?: unknown }).displayName;
if (typeof value === "string" && value.trim()) {
return normalizeTraceText(value);
}
}
return normalizeTraceText(inbound.tag) || "优选线路入口";
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface StorePlanSectionProps {
id?: string;
eyebrow?: string;
title: string;
children: ReactNode;
after?: ReactNode;
gridClassName?: string;
stacked?: boolean;
}
export function StorePlanSection({
id,
eyebrow,
title,
children,
after,
gridClassName,
stacked = false,
}: StorePlanSectionProps) {
return (
<section id={id} className="scroll-mt-8 space-y-6">
<div className="max-w-2xl">
{eyebrow && (
<p className="mb-1.5 inline-flex rounded-md border border-primary/15 bg-primary/10 px-2 py-0.5 text-xs font-medium tracking-wide text-primary">
{eyebrow}
</p>
)}
<h2 className="font-heading text-lg font-semibold tracking-[-0.02em] sm:text-xl">{title}</h2>
</div>
<div className={cn(stacked ? "space-y-5" : "grid gap-6 md:grid-cols-2 xl:grid-cols-3", !stacked && gridClassName)}>
{children}
</div>
{after}
</section>
);
}

View File

@@ -0,0 +1,11 @@
interface DisplayPlanBase {
id: string;
name: string;
sortOrder: number;
}
export function sortPlansForDisplay<T extends DisplayPlanBase>(plans: T[]): T[] {
return [...plans].sort((a, b) => {
return a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN");
});
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Film, ShoppingCart } from "lucide-react";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { purchaseStreaming } from "@/actions/user/purchase";
import { addStreamingPlanToCart } from "@/actions/user/cart";
import { StorePlanDescription } from "./plan-card-parts";
import { PlanAvailabilityBadges } from "./plan-availability-badges";
import { usePlanAvailabilityCheck } from "./use-plan-availability-check";
import type { StreamingPlan } from "./streaming-plan-types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
plan: StreamingPlan;
}
export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
const [loading, setLoading] = useState(false);
const [cartLoading, setCartLoading] = useState(false);
const router = useRouter();
const { checking, checkAvailability } = usePlanAvailabilityCheck(plan.id);
async function handlePurchase() {
setLoading(true);
try {
const orderId = await purchaseStreaming(plan.id);
router.push(`/pay/${orderId}`);
} catch (error) {
toast.error(getErrorMessage(error, "下单失败"));
} finally {
setLoading(false);
}
}
async function handleAddToCart() {
setCartLoading(true);
try {
await addStreamingPlanToCart(plan.id);
toast.success("已加入购物车");
onOpenChange(false);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "加入购物车失败"));
} finally {
setCartLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/15 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-amber-700 dark:text-amber-300">
<Film className="size-3.5" /> STREAMING
</div>
<DialogTitle>{plan.name}</DialogTitle>
<DialogDescription>
{plan.serviceName ?? plan.name} · {plan.durationDays} · ¥{plan.price.toFixed(0)}/{plan.durationDays}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto -mx-6 px-6 space-y-5">
{plan.description && (
<div>
<p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
</p>
<StorePlanDescription description={plan.description} />
</div>
)}
<div>
<p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
</p>
<PlanAvailabilityBadges
totalLimit={plan.totalLimit}
perUserLimit={plan.perUserLimit}
remainingCount={plan.remainingCount}
isAvailable={plan.isAvailable}
unavailableLabel="暂时售罄"
/>
</div>
{!plan.isAvailable && (
<p className="rounded-[1rem] border border-destructive/20 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive">
{plan.nextAvailableAt ? `,预计 ${plan.nextAvailableAt} 后可能补位` : ""}
</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
<Button
size="lg"
variant="outline"
onClick={handleAddToCart}
disabled={cartLoading || !plan.isAvailable}
>
<ShoppingCart className="size-4" />
{cartLoading ? "正在加入..." : "加入购物车"}
</Button>
<Button
size="lg"
onClick={handlePurchase}
disabled={loading || !plan.isAvailable}
>
{loading ? "正在保留..." : "立即支付"}
</Button>
</div>
{!plan.isAvailable && (
<Button
size="lg"
variant="outline"
className="w-full"
onClick={checkAvailability}
disabled={checking}
>
{checking ? "查询中..." : "查看补位时间"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { Film, Search, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StorePlanHeader } from "./plan-card-parts";
import { PlanAvailabilityBadges } from "./plan-availability-badges";
import { StreamingDetailDialog } from "./streaming-detail-dialog";
import type { StreamingPlan } from "./streaming-plan-types";
interface Props {
plan: StreamingPlan;
}
export function StreamingPlanCard({ plan }: Props) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<>
<article
id={`plan-${plan.id}`}
className="surface-card surface-lift group relative scroll-mt-24 flex flex-col overflow-hidden rounded-xl text-left"
>
<StorePlanHeader
eyebrow={
<span className="inline-flex items-center gap-2 text-amber-700 dark:text-amber-300">
<Film className="size-3.5" /> STREAMING
</span>
}
name={plan.name}
meta={plan.serviceName ? `${plan.serviceName} · ${plan.durationDays}` : `${plan.durationDays}`}
price={`¥${plan.price.toFixed(0)}`}
priceSuffix={`/${plan.durationDays}`}
/>
<div className="relative px-6">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Film className="size-3.5 text-amber-600" />
</p>
<p className="mt-1 text-sm font-medium">{plan.serviceName ?? "精选流媒体"}</p>
</div>
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Sparkles className="size-3.5 text-amber-600" />
</p>
<p className="mt-1 text-sm font-medium"></p>
</div>
</div>
</div>
<div className="mt-auto space-y-4 px-6 pb-6 pt-4">
<PlanAvailabilityBadges
totalLimit={plan.totalLimit}
perUserLimit={plan.perUserLimit}
remainingCount={plan.remainingCount}
isAvailable={plan.isAvailable}
unavailableLabel="暂时售罄"
/>
<Button
type="button"
className="w-full"
size="lg"
onClick={() => setDialogOpen(true)}
>
<Search className="size-4" />
</Button>
</div>
</article>
<StreamingDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} />
</>
);
}

View File

@@ -0,0 +1,16 @@
export interface StreamingPlan {
id: string;
name: string;
description: string | null;
sortOrder: number;
durationDays: number;
price: number;
serviceName: string | null;
totalLimit: number | null;
perUserLimit: number | null;
activeCount: number;
remainingCount: number | null;
remainingByUserLimit: number | null;
isAvailable: boolean;
nextAvailableAt: string | null;
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useEffectEvent, useSyncExternalStore } from "react";
import { fetchJson } from "@/lib/fetch-json";
export interface HopDetail {
hop: number;
ip: string;
geo: string;
latency: number;
}
export interface TraceItem {
carrier: string;
summary: string;
hopCount: number;
hops: HopDetail[];
updatedAt: string;
}
type TraceMap = Record<string, TraceItem[]>;
let traceData: TraceMap = {};
const listeners = new Set<() => void>();
function getSnapshot(): TraceMap {
return traceData;
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
function setTraceData(data: TraceMap) {
traceData = data;
for (const cb of listeners) cb();
}
export function useTraces(nodeId: string | null): TraceItem[] {
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nodeId ? data[nodeId] ?? [] : [];
}
export function TraceLoader({ nodeIds }: { nodeIds: string[] }) {
const nodeIdKey = nodeIds.join(",");
const load = useEffectEvent(async () => {
if (!nodeIdKey) return;
try {
const result = await fetchJson<TraceMap>(
`/api/traces?nodeIds=${nodeIdKey}`,
);
setTraceData(result);
} catch {
// Trace data is non-critical — silently ignore
}
});
useEffect(() => {
void load();
}, [nodeIdKey]);
return null;
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { queryPlanNextAvailability } from "@/actions/user/purchase";
import { getErrorMessage } from "@/lib/errors";
export function usePlanAvailabilityCheck(planId: string) {
const [checking, setChecking] = useState(false);
async function checkAvailability() {
setChecking(true);
try {
const result = await queryPlanNextAvailability(planId);
if (result.available) {
toast.success("这款套餐现在可以购买");
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(getErrorMessage(error, "暂时无法确认补位时间"));
} finally {
setChecking(false);
}
}
return { checking, checkAvailability };
}