mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
145
src/app/(user)/store/latency-detail-dialog.tsx
Normal file
145
src/app/(user)/store/latency-detail-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/app/(user)/store/latency-loader.tsx
Normal file
98
src/app/(user)/store/latency-loader.tsx
Normal 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;
|
||||
}
|
||||
135
src/app/(user)/store/page.tsx
Normal file
135
src/app/(user)/store/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/(user)/store/pending-order-banner.tsx
Normal file
54
src/app/(user)/store/pending-order-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/app/(user)/store/plan-availability-badges.tsx
Normal file
49
src/app/(user)/store/plan-availability-badges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/app/(user)/store/plan-card-parts.tsx
Normal file
44
src/app/(user)/store/plan-card-parts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/app/(user)/store/proxy-detail-dialog.tsx
Normal file
219
src/app/(user)/store/proxy-detail-dialog.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/app/(user)/store/proxy-plan-card.tsx
Normal file
86
src/app/(user)/store/proxy-plan-card.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/app/(user)/store/proxy-plan-types.ts
Normal file
31
src/app/(user)/store/proxy-plan-types.ts
Normal 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;
|
||||
}
|
||||
147
src/app/(user)/store/proxy-purchase-fields.tsx
Normal file
147
src/app/(user)/store/proxy-purchase-fields.tsx
Normal 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 || "优选线路入口";
|
||||
}
|
||||
163
src/app/(user)/store/proxy-signal-grid.tsx
Normal file
163
src/app/(user)/store/proxy-signal-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/(user)/store/proxy-trace-detail-dialog.tsx
Normal file
56
src/app/(user)/store/proxy-trace-detail-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/app/(user)/store/store-data.ts
Normal file
52
src/app/(user)/store/store-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
139
src/app/(user)/store/store-latency-recommendations.tsx
Normal file
139
src/app/(user)/store/store-latency-recommendations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/app/(user)/store/store-plan-mappers.ts
Normal file
137
src/app/(user)/store/store-plan-mappers.ts
Normal 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) || "优选线路入口";
|
||||
}
|
||||
39
src/app/(user)/store/store-plan-section.tsx
Normal file
39
src/app/(user)/store/store-plan-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(user)/store/store-recommendations.ts
Normal file
11
src/app/(user)/store/store-recommendations.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
137
src/app/(user)/store/streaming-detail-dialog.tsx
Normal file
137
src/app/(user)/store/streaming-detail-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/(user)/store/streaming-plan-card.tsx
Normal file
78
src/app/(user)/store/streaming-plan-card.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/(user)/store/streaming-plan-types.ts
Normal file
16
src/app/(user)/store/streaming-plan-types.ts
Normal 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;
|
||||
}
|
||||
64
src/app/(user)/store/trace-loader.tsx
Normal file
64
src/app/(user)/store/trace-loader.tsx
Normal 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;
|
||||
}
|
||||
28
src/app/(user)/store/use-plan-availability-check.ts
Normal file
28
src/app/(user)/store/use-plan-availability-check.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user