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,95 @@
import { CalendarDays, Sparkles, TicketCheck, UsersRound } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
interface AccountInviteCardProps {
inviteCode: string | null;
invitedUsersCount: number;
inviteRewardCount: number;
inviteRewardAmount: number;
createdAt: string;
isLoading: boolean;
onGenerate: () => void;
}
export function AccountInviteCard({
inviteCode,
invitedUsersCount,
inviteRewardCount,
inviteRewardAmount,
createdAt,
isLoading,
onGenerate,
}: AccountInviteCardProps) {
return (
<Card className="xl:sticky xl:top-6">
<CardHeader className="pb-1">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<TicketCheck className="size-4" />
</span>
<div className="min-w-0 space-y-1">
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border border-primary/12 bg-primary/[0.04] p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-primary">
<Sparkles className="size-3" /> INVITE TOKEN
</div>
<div className="space-y-3">
<Input
value={inviteCode ?? "暂未生成"}
disabled
className="font-mono text-base tracking-[0.18em]"
/>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
{inviteCode && <CopyButton text={inviteCode} />}
<Button
type="button"
variant={inviteCode ? "outline" : "default"}
onClick={onGenerate}
disabled={isLoading}
className="w-full"
>
{isLoading ? "处理中..." : inviteCode ? "重新生成" : "生成邀请码"}
</Button>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<UsersRound className="size-3.5" />
</div>
<p className="mt-2 text-2xl font-semibold tabular-nums">{invitedUsersCount}</p>
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<Sparkles className="size-3.5" />
</div>
<p className="mt-2 text-2xl font-semibold tabular-nums">¥{inviteRewardAmount.toFixed(2)}</p>
<p className="mt-1 text-xs text-muted-foreground">{inviteRewardCount} </p>
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3 sm:col-span-2 xl:col-span-1 2xl:col-span-2">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<CalendarDays className="size-3.5" />
</div>
<p className="mt-2 text-sm font-medium leading-6">{createdAt}</p>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,64 @@
import { LockKeyhole, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type AccountFormAction = (formData: FormData) => void | Promise<void>;
interface AccountPasswordCardProps {
email: string;
isSaving: boolean;
onSubmit: AccountFormAction;
}
export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswordCardProps) {
return (
<Card>
<CardHeader className="pb-1">
<div className="flex items-start 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">
<LockKeyhole className="size-4" />
</span>
<div className="min-w-0 space-y-1">
<CardTitle></CardTitle>
<CardDescription>使 6 </CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form id="account-password-form" action={onSubmit} className="space-y-5">
<input type="email" name="username" value={email} autoComplete="username" readOnly hidden />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="currentPassword"></Label>
<Input id="currentPassword" name="currentPassword" type="password" autoComplete="current-password" required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="newPassword"></Label>
<Input id="newPassword" name="newPassword" type="password" minLength={6} autoComplete="new-password" required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="confirmPassword"></Label>
<Input id="confirmPassword" name="confirmPassword" type="password" minLength={6} autoComplete="new-password" required />
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="inline-flex items-center gap-2 text-xs leading-5 text-muted-foreground">
<ShieldCheck className="size-3.5 text-primary" />
</p>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "更新中..." : "更新密码"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,60 @@
import { ShieldCheck, UserRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { AccountPanelUser } from "../account-types";
type AccountFormAction = (formData: FormData) => void | Promise<void>;
interface AccountProfileCardProps {
user: Pick<AccountPanelUser, "email" | "name">;
isSaving: boolean;
onSubmit: AccountFormAction;
}
export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileCardProps) {
return (
<Card>
<CardHeader className="pb-1">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UserRound className="size-4" />
</span>
<div className="min-w-0 space-y-1">
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form action={onSubmit} className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="email"></Label>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
<ShieldCheck className="size-3" />
</span>
</div>
<Input id="email" value={user.email} disabled />
</div>
</div>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "保存中..." : "保存资料"}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,17 @@
import { Megaphone } from "lucide-react";
export function SiteNotice({ notice }: { notice: string }) {
return (
<div className="surface-card overflow-hidden rounded-xl px-4 py-3 text-sm leading-6 text-muted-foreground">
<div className="flex gap-3">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
<Megaphone className="size-4" />
</span>
<div>
<p className="text-xs font-medium tracking-wide text-amber-700 dark:text-amber-300"></p>
<p className="mt-0.5 whitespace-pre-wrap text-pretty">{notice}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { prisma } from "@/lib/prisma";
import { formatDate } from "@/lib/utils";
import { getAppConfig } from "@/services/app-config";
import type { AccountPanelUser } from "./account-types";
export async function getAccountPageData(userId: string): Promise<{
user: AccountPanelUser;
siteNotice: string | null;
}> {
const [user, rewardAggregate, config] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
email: true,
name: true,
inviteCode: true,
createdAt: true,
_count: {
select: {
invitedUsers: true,
},
},
},
}),
prisma.inviteRewardLedger.aggregate({
where: { inviterId: userId, status: "ISSUED" },
_count: { _all: true },
_sum: { rewardAmount: true },
}),
getAppConfig(),
]);
return {
user: {
email: user.email,
name: user.name,
inviteCode: user.inviteCode,
createdAt: formatDate(user.createdAt),
invitedUsersCount: user._count.invitedUsers,
inviteRewardCount: rewardAggregate._count._all,
inviteRewardAmount: Number(rewardAggregate._sum.rewardAmount ?? 0),
},
siteNotice: config.siteNotice,
};
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
changeAccountPassword,
generateInviteCode,
updateAccountProfile,
} from "@/actions/user/account";
import { getErrorMessage } from "@/lib/errors";
import { toast } from "sonner";
import { AccountInviteCard } from "./_components/account-invite-card";
import { AccountPasswordCard } from "./_components/account-password-card";
import { AccountProfileCard } from "./_components/account-profile-card";
import type { AccountPanelUser } from "./account-types";
interface Props {
user: AccountPanelUser;
}
export function AccountPanel({ user }: Props) {
const router = useRouter();
const [profileSaving, setProfileSaving] = useState(false);
const [passwordSaving, setPasswordSaving] = useState(false);
const [inviteCode, setInviteCode] = useState(user.inviteCode);
const [inviteLoading, setInviteLoading] = useState(false);
async function handleProfileSubmit(formData: FormData) {
setProfileSaving(true);
try {
await updateAccountProfile(formData);
toast.success("资料已更新");
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "更新资料失败"));
} finally {
setProfileSaving(false);
}
}
async function handlePasswordSubmit(formData: FormData) {
setPasswordSaving(true);
try {
await changeAccountPassword(formData);
toast.success("密码已更新");
(document.getElementById("account-password-form") as HTMLFormElement | null)?.reset();
} catch (error) {
toast.error(getErrorMessage(error, "修改密码失败"));
} finally {
setPasswordSaving(false);
}
}
async function handleInviteCode() {
setInviteLoading(true);
try {
const code = await generateInviteCode();
setInviteCode(code);
toast.success("邀请码已生成");
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成邀请码失败"));
} finally {
setInviteLoading(false);
}
}
return (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.82fr)]">
<div className="space-y-5">
<AccountProfileCard
user={user}
isSaving={profileSaving}
onSubmit={handleProfileSubmit}
/>
<AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} />
</div>
<AccountInviteCard
inviteCode={inviteCode}
invitedUsersCount={user.invitedUsersCount}
inviteRewardCount={user.inviteRewardCount}
inviteRewardAmount={user.inviteRewardAmount}
createdAt={user.createdAt}
isLoading={inviteLoading}
onGenerate={() => {
void handleInviteCode();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export interface AccountPanelUser {
email: string;
name: string | null;
inviteCode: string | null;
createdAt: string;
invitedUsersCount: number;
inviteRewardCount: number;
inviteRewardAmount: number;
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { AccountPanel } from "./account-panel";
import { getAccountPageData } from "./account-data";
import { SiteNotice } from "./_components/site-notice";
export const metadata: Metadata = {
title: "账户中心",
description: "管理账户资料、安全设置与邀请码。",
};
export default async function AccountPage() {
const session = await getServerSession(authOptions);
const { user, siteNotice } = await getAccountPageData(session!.user.id);
return (
<PageShell>
<PageHeader
eyebrow="账户中心"
title="个人资料与安全"
/>
{siteNotice && <SiteNotice notice={siteNotice} />}
<AccountPanel user={user} />
</PageShell>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Gift, ShoppingCart, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { StatusBadge } from "@/components/shared/status-badge";
import { getErrorMessage } from "@/lib/errors";
import { checkoutCart, clearCart, removeCartItem } from "@/actions/user/cart";
interface CartItemView {
id: string;
name: string;
type: "PROXY" | "STREAMING";
categoryName: string;
description: string | null;
durationDays: number;
amount: number;
priceLabel: string;
trafficGb: number | null;
nodeName: string | null;
serviceName: string | null;
inboundName: string;
}
interface CouponView {
id: string;
code: string;
name: string;
description: string | null;
private: boolean;
thresholdAmount: number | null;
discountType: "AMOUNT_OFF" | "PERCENT_OFF";
discountValue: number;
}
interface PromotionView {
id: string;
name: string;
thresholdAmount: number;
discountAmount: number;
}
export function CartClient({
items,
subtotal,
coupons,
promotions,
}: {
items: CartItemView[];
subtotal: number;
coupons: CouponView[];
promotions: PromotionView[];
}) {
const router = useRouter();
const [couponCode, setCouponCode] = useState("");
const [loadingId, setLoadingId] = useState<string | null>(null);
const [checkingOut, setCheckingOut] = useState(false);
const bestPromotion = useMemo(
() => promotions.filter((rule) => subtotal >= rule.thresholdAmount).sort((a, b) => b.discountAmount - a.discountAmount)[0] ?? null,
[promotions, subtotal],
);
async function handleRemove(itemId: string) {
setLoadingId(itemId);
try {
await removeCartItem(itemId);
toast.success("已移出购物车");
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "移出失败"));
} finally {
setLoadingId(null);
}
}
async function handleClear() {
setLoadingId("clear");
try {
await clearCart();
toast.success("购物车已清空");
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "清空失败"));
} finally {
setLoadingId(null);
}
}
async function handleCheckout() {
setCheckingOut(true);
try {
const orderId = await checkoutCart(couponCode);
toast.success("订单已为你保留");
router.push(`/pay/${orderId}`);
} catch (error) {
toast.error(getErrorMessage(error, "结算失败"));
} finally {
setCheckingOut(false);
}
}
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
<section className="space-y-4">
{items.map((item) => (
<article key={item.id} className="surface-card surface-lift overflow-hidden rounded-xl p-4">
<div className="liquid-orb -right-10 -top-14 size-28 bg-primary/12" />
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={item.type === "PROXY" ? "info" : "warning"}>
{item.type === "PROXY" ? "代理" : "流媒体"}
</StatusBadge>
<StatusBadge>{item.categoryName}</StatusBadge>
<StatusBadge>{item.durationDays} </StatusBadge>
</div>
<div>
<h3 className="text-xl font-semibold tracking-[-0.04em]">{item.name}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground text-pretty">
{item.type === "PROXY"
? `${item.nodeName ?? "优选区域"} · ${item.inboundName} · ${item.priceLabel}`
: `${item.serviceName ?? "精选服务"} · ${item.priceLabel}`}
</p>
</div>
{item.description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground text-pretty">{item.description}</p>
)}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 lg:flex-col lg:items-end">
<p className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">
¥{item.amount.toFixed(2)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleRemove(item.id)}
disabled={loadingId === item.id}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</article>
))}
</section>
<aside className="surface-card sticky top-24 h-fit space-y-4 rounded-xl p-4">
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<ShoppingCart className="size-4" />
</span>
<div>
<h2 className="font-semibold"></h2>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="flex justify-between gap-3">
<span className="text-muted-foreground"></span>
<span className="font-semibold tabular-nums">¥{subtotal.toFixed(2)}</span>
</div>
{bestPromotion && (
<div className="flex justify-between gap-3 text-primary">
<span> · {bestPromotion.name}</span>
<span className="font-semibold tabular-nums">-¥{bestPromotion.discountAmount.toFixed(2)}</span>
</div>
)}
<div className="border-t border-border/45 pt-3">
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
</div>
<div className="space-y-2">
<label htmlFor="couponCode" className="flex items-center gap-2 text-sm font-semibold">
<Gift className="size-4 text-primary" />
</label>
<div className="flex gap-2">
<Input
id="couponCode"
value={couponCode}
onChange={(event) => setCouponCode(event.target.value)}
placeholder="输入优惠码"
/>
{couponCode && (
<Button type="button" variant="outline" size="icon" onClick={() => setCouponCode("")} aria-label="清空优惠码">
<X className="size-4" />
</Button>
)}
</div>
{coupons.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{coupons.slice(0, 4).map((coupon) => (
<button
key={`${coupon.id}-${coupon.private ? "private" : "public"}`}
type="button"
className="rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary transition hover:-translate-y-0.5"
onClick={() => setCouponCode(coupon.code)}
>
{coupon.code}
</button>
))}
</div>
)}
</div>
{promotions.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/15 bg-amber-500/10 p-3">
<p className="flex items-center gap-2 text-sm font-semibold text-amber-700 dark:text-amber-300">
<Sparkles className="size-4" />
</p>
{promotions.map((rule) => (
<p key={rule.id} className="text-xs leading-5 text-muted-foreground">
¥{rule.thresholdAmount.toFixed(2)} ¥{rule.discountAmount.toFixed(2)} · {rule.name}
</p>
))}
</div>
)}
<div className="grid gap-2">
<Button size="lg" onClick={() => void handleCheckout()} disabled={checkingOut || items.length === 0}>
{checkingOut ? "正在整理订单..." : "结算并去支付"}
<ArrowRight className="size-4" />
</Button>
<Button type="button" variant="outline" size="lg" onClick={() => void handleClear()} disabled={loadingId === "clear"}>
</Button>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { prisma } from "@/lib/prisma";
import { getPlanPurchasePrice } from "@/services/commerce";
function getInboundDisplayName(inbound: { tag: string; settings: unknown } | null) {
if (!inbound) return "优选线路入口";
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 value.trim();
}
return inbound.tag || "优选线路入口";
}
export async function getCartPageData(userId: string) {
const [items, publicCoupons, grantedCoupons, promotions] = await Promise.all([
prisma.shoppingCartItem.findMany({
where: { userId },
include: {
plan: {
include: {
category: true,
streamingService: true,
node: true,
},
},
selectedInbound: true,
},
orderBy: { createdAt: "asc" },
}),
prisma.coupon.findMany({
where: { isActive: true, isPublic: true },
orderBy: { createdAt: "desc" },
take: 6,
}),
prisma.couponGrant.findMany({
where: { userId, usedOrderId: null, coupon: { isActive: true } },
include: { coupon: true },
orderBy: { createdAt: "desc" },
take: 6,
}),
prisma.promotionRule.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
take: 5,
}),
]);
const mappedItems = items.map((item) => {
const price = getPlanPurchasePrice(item.plan, item.trafficGb);
return {
id: item.id,
planId: item.planId,
name: item.plan.name,
type: item.plan.type,
categoryName: item.plan.category?.name ?? (item.plan.type === "PROXY" ? "代理连接" : "流媒体共享"),
description: item.plan.description,
durationDays: item.plan.durationDays,
amount: price.amount,
priceLabel: price.label,
trafficGb: price.trafficGb,
nodeName: item.plan.node?.name ?? null,
serviceName: item.plan.streamingService?.name ?? null,
inboundName: getInboundDisplayName(item.selectedInbound),
};
});
const subtotal = mappedItems.reduce((sum, item) => sum + item.amount, 0);
const coupons = [
...grantedCoupons.map((grant) => ({
id: grant.coupon.id,
code: grant.coupon.code,
name: grant.coupon.name,
description: grant.coupon.description,
private: true,
thresholdAmount: grant.coupon.thresholdAmount == null ? null : Number(grant.coupon.thresholdAmount),
discountType: grant.coupon.discountType,
discountValue: Number(grant.coupon.discountValue),
})),
...publicCoupons.map((coupon) => ({
id: coupon.id,
code: coupon.code,
name: coupon.name,
description: coupon.description,
private: false,
thresholdAmount: coupon.thresholdAmount == null ? null : Number(coupon.thresholdAmount),
discountType: coupon.discountType,
discountValue: Number(coupon.discountValue),
})),
];
return {
items: mappedItems,
subtotal,
coupons,
promotions: promotions.map((rule) => ({
id: rule.id,
name: rule.name,
thresholdAmount: Number(rule.thresholdAmount),
discountAmount: Number(rule.discountAmount),
})),
};
}

View File

@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { ShoppingBag, ShoppingCart } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { EmptyState, PageHeader, PageShell } from "@/components/shared/page-shell";
import { buttonVariants } from "@/components/ui/button";
import { CartClient } from "./cart-client";
import { getCartPageData } from "./cart-data";
export const metadata: Metadata = {
title: "购物车",
description: "确认套餐清单并统一结算。",
};
export default async function CartPage() {
const session = await getServerSession(authOptions);
const data = await getCartPageData(session!.user.id);
return (
<PageShell>
<PageHeader
eyebrow="结算中心"
title="购物车"
/>
{data.items.length === 0 ? (
<EmptyState
eyebrow="购物车"
icon={<ShoppingCart className="size-5" />}
title="还没有加入任何套餐"
description="从商店挑选适合你的连接或服务,加入购物车后再统一结算。"
action={
<Link href="/store" className={buttonVariants()}>
<ShoppingBag className="size-4" />
</Link>
}
/>
) : (
<CartClient {...data} />
)}
</PageShell>
);
}

View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { ArrowRight, ReceiptText, Radio } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
export function DashboardActions() {
return (
<>
<Link href="/store" className={buttonVariants({ size: "lg" })}>
<ArrowRight className="size-4" />
</Link>
<Link href="/subscriptions" className={buttonVariants({ variant: "outline", size: "lg" })}>
<Radio className="size-4" />
</Link>
<Link href="/orders" className={buttonVariants({ variant: "ghost", size: "lg" })}>
<ReceiptText className="size-4" />
</Link>
</>
);
}

View File

@@ -0,0 +1,51 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { MetricCard } from "@/components/shared/metric-card";
import { formatBytes } from "@/lib/utils";
import type { TrafficOverview } from "../dashboard-types";
interface DashboardMetricGridProps {
activeCount: number;
proxyCount: number;
streamingCount: number;
pendingOrderCount: number;
paidOrderCount: number;
nearestExpiry: Date | null;
traffic: TrafficOverview;
}
export function DashboardMetricGrid({
activeCount,
proxyCount,
streamingCount,
pendingOrderCount,
paidOrderCount,
nearestExpiry,
traffic,
}: DashboardMetricGridProps) {
return (
<section className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="活跃订阅"
value={activeCount}
description={`代理 ${proxyCount} · 流媒体 ${streamingCount}`}
/>
<MetricCard
label="待支付订单"
value={pendingOrderCount}
description={`已完成订单 ${paidOrderCount}`}
/>
<MetricCard
label="总流量消耗"
value={formatBytes(traffic.totalUsed)}
description={`剩余 ${formatBytes(traffic.totalRemaining)}`}
/>
<MetricCard
label="最近到期"
value={nearestExpiry ? format(nearestExpiry, "yyyy-MM-dd", { locale: zhCN }) : "暂无"}
valueClassName="text-xl"
description="到期请及时续费避免中断"
/>
</section>
);
}

View File

@@ -0,0 +1,93 @@
import Link from "next/link";
import { ArrowRight, CircleCheck, LifeBuoy, ShoppingBag } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FirstRunGuideProps {
pendingOrderCount: number;
}
const steps = [
{
title: "选择套餐",
description: "从商店挑选代理或流媒体套餐。",
},
{
title: "完成支付",
description: "支付成功后自动开通订阅。",
},
{
title: "导入使用",
description: "复制订阅链接或扫码导入客户端。",
},
];
export function FirstRunGuide({ pendingOrderCount }: FirstRunGuideProps) {
const hasPendingOrder = pendingOrderCount > 0;
return (
<section className="surface-card overflow-hidden rounded-xl p-5 sm:p-6">
<div className="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
<div className="space-y-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
</p>
<div className="space-y-2">
<h2 className="text-display text-2xl font-semibold text-balance sm:text-3xl">
</h2>
<p className="max-w-lg text-sm leading-6 text-muted-foreground text-pretty">
使
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={hasPendingOrder ? "/orders" : "/store"}
className={buttonVariants({ size: "lg" })}
>
{hasPendingOrder ? "继续支付订单" : "去商店选择套餐"}
<ArrowRight className="size-4" />
</Link>
<Link
href="/support"
className={buttonVariants({ variant: "outline", size: "lg" })}
>
<LifeBuoy className="size-4" />
</Link>
</div>
</div>
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="space-y-2">
{steps.map((step, index) => (
<div
key={step.title}
className={cn(
"group flex gap-3 rounded-lg border px-3 py-3 transition-colors",
index === 1 && hasPendingOrder
? "border-primary/20 bg-primary/8"
: "border-border/50 bg-card",
)}
>
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{index === 0 ? (
<ShoppingBag className="size-3.5" />
) : (
<CircleCheck className="size-3.5" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium">{step.title}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground text-pretty">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export function MaintenanceNotice({ message }: { message: string }) {
return (
<section className="rounded-xl border-amber-200 bg-amber-50 px-5 py-3.5 text-sm text-amber-900">
{message}
</section>
);
}

View File

@@ -0,0 +1,50 @@
import { Gauge } from "lucide-react";
import { EmptyState } from "@/components/shared/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { formatBytes } from "@/lib/utils";
import type { TrafficOverview } from "../dashboard-types";
interface ProxyTrafficOverviewCardProps {
proxyCount: number;
traffic: TrafficOverview;
}
export function ProxyTrafficOverviewCard({
proxyCount,
traffic,
}: ProxyTrafficOverviewCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{proxyCount === 0 ? (
<EmptyState
icon={<Gauge className="size-5" />}
title="还没有代理流量"
description="购买代理套餐后这里将显示流量用量汇总。"
className="border-0 bg-transparent px-3 py-8"
/>
) : traffic.totalLimit > 0 ? (
<>
<div className="flex justify-between text-sm">
<span> {formatBytes(traffic.totalUsed)}</span>
<span> {formatBytes(traffic.totalLimit)}</span>
</div>
<Progress value={traffic.usagePercent} className="h-2" />
<p className="text-xs text-muted-foreground">
{formatBytes(traffic.totalRemaining)}
{Math.max(0, 100 - traffic.usagePercent)}%
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
import { TrafficTrendChartLazy } from "@/components/shared/traffic-trend-chart-lazy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { DashboardTrafficTrendPoint } from "../dashboard-data";
export function TrafficSection({ trend }: { trend: DashboardTrafficTrendPoint[] }) {
return (
<section>
<Card>
<CardHeader>
<CardTitle className="text-base"> 7 </CardTitle>
</CardHeader>
<CardContent>
<TrafficTrendChartLazy
data={trend}
color="oklch(0.52 0.13 172)"
/>
</CardContent>
</Card>
</section>
);
}
export function TrafficSectionSkeleton() {
return (
<section>
<Card>
<CardHeader>
<CardTitle className="text-base"> 7 </CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] animate-pulse rounded bg-muted" />
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,63 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarClock } from "lucide-react";
import { EmptyState } from "@/components/shared/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusBadge } from "@/components/shared/status-badge";
import type { DashboardSubscription, UpcomingExpiry } from "../dashboard-types";
export function UpcomingExpiryCard({ items }: { items: UpcomingExpiry[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="size-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<EmptyState
icon={<CalendarClock className="size-5" />}
title="暂时没有到期压力"
description="有活跃订阅后,这里会提前展示续费提醒。"
className="border-0 bg-transparent px-3 py-8"
/>
) : (
<div className="space-y-2">
{items.map(({ sub, daysLeft }) => (
<div
key={sub.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5 transition-colors hover:border-primary/15 hover:bg-primary/5"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{sub.plan.name}</p>
<p className="truncate text-xs text-muted-foreground">
{getSubscriptionSubtitle(sub)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-semibold tabular-nums">
{format(sub.endDate, "MM-dd HH:mm", { locale: zhCN })}
</p>
<StatusBadge tone={daysLeft <= 3 ? "warning" : "neutral"}>
{daysLeft}
</StatusBadge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function getSubscriptionSubtitle(sub: DashboardSubscription) {
if (sub.plan.type === "PROXY") {
return sub.nodeClient
? `${sub.nodeClient.inbound.protocol} · ${sub.nodeClient.inbound.tag}`
: "代理套餐";
}
return sub.streamingSlot ? sub.streamingSlot.service.name : "流媒体套餐";
}

View File

@@ -0,0 +1,52 @@
import type {
DashboardSubscription,
TrafficOverview,
UpcomingExpiry,
} from "./dashboard-types";
export function getProxySubscriptions(subscriptions: DashboardSubscription[]) {
return subscriptions.filter((sub) => sub.plan.type === "PROXY");
}
export function getStreamingSubscriptions(subscriptions: DashboardSubscription[]) {
return subscriptions.filter((sub) => sub.plan.type === "STREAMING");
}
export function getTrafficOverview(subscriptions: DashboardSubscription[]): TrafficOverview {
const totalUsed = subscriptions.reduce(
(sum, sub) => sum + Number(sub.trafficUsed),
0,
);
const totalLimit = subscriptions.reduce(
(sum, sub) => sum + Number(sub.trafficLimit ?? 0),
0,
);
const totalRemaining = Math.max(0, totalLimit - totalUsed);
const usagePercent =
totalLimit > 0
? Math.min(100, Math.round((totalUsed / totalLimit) * 100))
: 0;
return { totalUsed, totalLimit, totalRemaining, usagePercent };
}
export function getProxyClientIds(subscriptions: DashboardSubscription[]) {
return subscriptions
.map((sub) => sub.nodeClient?.id)
.filter((id): id is string => Boolean(id));
}
export function getUpcomingExpiries(
subscriptions: DashboardSubscription[],
now = new Date(),
): UpcomingExpiry[] {
const nowMs = now.getTime();
return subscriptions.slice(0, 5).map((sub) => ({
sub,
daysLeft: Math.max(
0,
Math.ceil((sub.endDate.getTime() - nowMs) / (1000 * 60 * 60 * 24)),
),
}));
}

View File

@@ -0,0 +1,63 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { prisma } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
export interface DashboardTrafficTrendPoint {
date: string;
valueGb: number;
}
export async function getDashboardData(userId: string) {
const [activeSubs, pendingOrderCount, paidOrderCount, config] =
await Promise.all([
prisma.userSubscription.findMany({
where: { userId, status: "ACTIVE" },
include: {
plan: true,
nodeClient: { include: { inbound: true } },
streamingSlot: { include: { service: true } },
},
orderBy: { endDate: "asc" },
}),
prisma.order.count({ where: { userId, status: "PENDING" } }),
prisma.order.count({ where: { userId, status: "PAID" } }),
getAppConfig(),
]);
return { activeSubs, pendingOrderCount, paidOrderCount, config };
}
export async function getDashboardTrafficTrend(
clientIds: string[],
): Promise<DashboardTrafficTrendPoint[]> {
const trafficWindowStart = new Date();
trafficWindowStart.setDate(trafficWindowStart.getDate() - 7);
const recentTrafficLogs =
clientIds.length > 0
? await prisma.trafficLog.findMany({
where: {
clientId: { in: clientIds },
timestamp: { gte: trafficWindowStart },
},
select: { timestamp: true, upload: true, download: true },
orderBy: { timestamp: "asc" },
})
: [];
const trafficByDay = new Map<string, number>();
for (const log of recentTrafficLogs) {
const key = format(log.timestamp, "MM-dd", { locale: zhCN });
const current = trafficByDay.get(key) ?? 0;
trafficByDay.set(
key,
current + Number(log.upload + log.download) / 1024 ** 3,
);
}
return Array.from(trafficByDay.entries()).map(([date, valueGb]) => ({
date,
valueGb: Number(valueGb.toFixed(2)),
}));
}

View File

@@ -0,0 +1,21 @@
import type { Prisma } from "@prisma/client";
export type DashboardSubscription = Prisma.UserSubscriptionGetPayload<{
include: {
plan: true;
nodeClient: { include: { inbound: true } };
streamingSlot: { include: { service: true } };
};
}>;
export interface TrafficOverview {
totalUsed: number;
totalLimit: number;
totalRemaining: number;
usagePercent: number;
}
export interface UpcomingExpiry {
sub: DashboardSubscription;
daysLeft: number;
}

View File

@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { getDashboardData, getDashboardTrafficTrend } from "./dashboard-data";
import {
getProxyClientIds,
getProxySubscriptions,
getStreamingSubscriptions,
getTrafficOverview,
getUpcomingExpiries,
} from "./dashboard-calculations";
import { DashboardActions } from "./_components/dashboard-actions";
import { DashboardMetricGrid } from "./_components/dashboard-metric-grid";
import { FirstRunGuide } from "./_components/first-run-guide";
import { MaintenanceNotice } from "./_components/maintenance-notice";
import { ProxyTrafficOverviewCard } from "./_components/proxy-traffic-overview-card";
import { TrafficSection } from "./_components/traffic-section";
import { UpcomingExpiryCard } from "./_components/upcoming-expiry-card";
export const metadata: Metadata = {
title: "我的概览",
description: "查看订阅概况、流量趋势与到期提醒。",
};
export default async function UserDashboard() {
const session = await getServerSession(authOptions);
const userId = session!.user.id;
const { activeSubs, pendingOrderCount, paidOrderCount, config } =
await getDashboardData(userId);
const proxyActive = getProxySubscriptions(activeSubs);
const streamingActive = getStreamingSubscriptions(activeSubs);
const trafficOverview = getTrafficOverview(proxyActive);
const nearestExpiry = activeSubs[0]?.endDate ?? null;
const clientIds = getProxyClientIds(proxyActive);
const upcomingExpiries = getUpcomingExpiries(activeSubs);
const trafficTrend = await getDashboardTrafficTrend(clientIds);
const dashboardDescription =
activeSubs.length === 0
? "完成首个订阅后这里将显示用量总览。"
: `${activeSubs.length} 个活跃订阅 · ${pendingOrderCount} 个待支付订单`;
return (
<PageShell>
{config.maintenanceNotice && (
<MaintenanceNotice message={config.maintenanceNotice} />
)}
<PageHeader
eyebrow="账户概览"
title={`你好,${session!.user.name || "用户"}`}
description={dashboardDescription}
actions={<DashboardActions />}
/>
{activeSubs.length === 0 && (
<FirstRunGuide pendingOrderCount={pendingOrderCount} />
)}
<DashboardMetricGrid
activeCount={activeSubs.length}
proxyCount={proxyActive.length}
streamingCount={streamingActive.length}
pendingOrderCount={pendingOrderCount}
paidOrderCount={paidOrderCount}
nearestExpiry={nearestExpiry}
traffic={trafficOverview}
/>
<section className="grid gap-5 xl:grid-cols-2">
<ProxyTrafficOverviewCard
proxyCount={proxyActive.length}
traffic={trafficOverview}
/>
<UpcomingExpiryCard items={upcomingExpiries} />
</section>
{proxyActive.length > 0 && <TrafficSection trend={trafficTrend} />}
</PageShell>
);
}

26
src/app/(user)/error.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function UserError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-md">
<CardContent className="py-10 text-center space-y-5">
<h1 className="text-xl font-semibold tracking-tight"></h1>
<p className="text-sm text-destructive">
{error.message || "页面加载失败,请稍后重试。"}
</p>
<Button onClick={reset} className="h-10"></Button>
</CardContent>
</Card>
</div>
);
}

52
src/app/(user)/layout.tsx Normal file
View File

@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
import { UserSidebar } from "@/components/user/sidebar";
import { UserMobileNav } from "@/components/user/mobile-nav";
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
import { getUnreadNotificationCount } from "./notifications/notifications-data";
import { PageTransition } from "@/components/shared/page-transition";
export const metadata: Metadata = {
title: {
default: "用户中心",
template: "%s | J-Board",
},
description: "管理套餐、订单、订阅和账户信息。",
};
export default async function UserLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/login");
}
if (session.user.role === "ADMIN") {
redirect("/admin/dashboard");
}
const userName = session.user.name || session.user.email || "";
const unreadCount = await getUnreadNotificationCount(session.user.id);
return (
<div className="flex h-[100dvh] overflow-hidden p-0 md:p-3">
<div className="hidden shrink-0 md:flex">
<UserSidebar userName={userName} unreadCount={unreadCount} />
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden md:pl-3">
<UserMobileNav userName={userName} unreadCount={unreadCount} />
<main className="flex-1 overflow-auto px-3 py-4 sm:px-5 sm:py-6 md:pt-0 lg:px-7 lg:pb-7">
<Suspense fallback={null}>
<AnnouncementLoader userId={session.user.id} role="USER" />
</Suspense>
<PageTransition>{children}</PageTransition>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function UserLoading() {
return (
<div className="space-y-8 animate-fade-in-up">
<Skeleton className="h-8 w-48" />
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="py-6">
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
export default function UserNotFound() {
return (
<section className="surface-card mx-auto w-full max-w-md space-y-4 rounded-xl p-6 text-center">
<p className="text-xs font-medium tracking-wide text-primary">404</p>
<h1 className="text-display text-2xl font-semibold"></h1>
<p className="text-sm leading-6 text-muted-foreground">
访
</p>
<div className="flex flex-wrap justify-center gap-2.5">
<Link href="/dashboard" className={buttonVariants({ size: "lg" })}>
</Link>
<Link href="/support" className={buttonVariants({ variant: "outline", size: "lg" })}>
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import type { UserNotification } from "@prisma/client";
import { StatusBadge } from "@/components/shared/status-badge";
import { cn, formatDate } from "@/lib/utils";
import { NotificationActions } from "../notification-actions";
import {
getNotificationLevelTone,
getNotificationReadLabel,
getNotificationReadTone,
notificationLevelLabels,
} from "../notifications-calculations";
interface NotificationItemProps {
notification: UserNotification;
}
export function NotificationItem({ notification }: NotificationItemProps) {
return (
<article
className={cn(
"rounded-2xl border border-border/50 bg-muted/25 p-4 transition-colors hover:bg-muted/45",
!notification.isRead && "border-primary/20 bg-primary/[0.04]",
)}
>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
<div className="min-w-0 space-y-2.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium tracking-tight text-foreground">{notification.title}</h3>
<StatusBadge tone={getNotificationReadTone(notification.isRead)}>
{getNotificationReadLabel(notification.isRead)}
</StatusBadge>
<StatusBadge tone={getNotificationLevelTone(notification.level)}>
{notificationLevelLabels[notification.level]}
</StatusBadge>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">
{notification.body}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<time dateTime={notification.createdAt.toISOString()}>
{formatDate(notification.createdAt)}
</time>
{notification.link && (
<Link href={notification.link} className="font-medium text-primary hover:underline">
</Link>
)}
</div>
</div>
<NotificationActions notificationId={notification.id} isRead={notification.isRead} />
</div>
</article>
);
}

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import type { UserNotification } from "@prisma/client";
import { Bell } from "lucide-react";
import { EmptyState } from "@/components/shared/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { NotificationItem } from "./notification-item";
interface NotificationListProps {
notifications: UserNotification[];
unreadCount: number;
}
export function NotificationList({ notifications, unreadCount }: NotificationListProps) {
return (
<Card className="rounded-2xl">
<CardHeader className="border-b border-border/50">
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
<span className="text-sm font-normal text-muted-foreground"> {unreadCount}</span>
</CardTitle>
</CardHeader>
<CardContent>
{notifications.length === 0 ? (
<EmptyState
icon={<Bell className="size-5" />}
title="现在很安静"
description="支付结果、订阅状态和系统提醒会集中出现在这里。"
action={
<Link href="/store" className={buttonVariants({ variant: "outline" })}>
</Link>
}
className="border-0 bg-transparent py-10"
/>
) : (
<div role="list" className="space-y-3">
{notifications.map((notification) => (
<div key={notification.id} role="listitem">
<NotificationItem notification={notification} />
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { Button } from "@/components/ui/button";
import {
markEveryNotificationAsRead,
markNotificationAsRead,
removeNotification,
removeReadNotifications,
} from "@/actions/user/notifications";
import { getErrorMessage } from "@/lib/errors";
import { toast } from "sonner";
export function NotificationActions({
notificationId,
isRead,
}: {
notificationId: string;
isRead: boolean;
}) {
return (
<div className="flex shrink-0 flex-wrap gap-2 md:justify-end">
{!isRead && (
<Button
size="sm"
variant="outline"
onClick={() => {
void (async () => {
try {
await markNotificationAsRead(notificationId);
toast.success("已标记为已读");
} catch (error) {
toast.error(getErrorMessage(error, "操作失败"));
}
})();
}}
>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => {
void (async () => {
try {
await removeNotification(notificationId);
toast.success("通知已删除");
} catch (error) {
toast.error(getErrorMessage(error, "删除失败"));
}
})();
}}
>
</Button>
</div>
);
}
export function NotificationBulkAction({
unreadCount,
readCount,
}: {
unreadCount: number;
readCount: number;
}) {
return (
<div className="flex flex-wrap gap-2 sm:justify-end">
{unreadCount > 0 && (
<Button
variant="outline"
onClick={() => {
void (async () => {
try {
await markEveryNotificationAsRead();
toast.success("全部消息已标记为已读");
} catch (error) {
toast.error(getErrorMessage(error, "操作失败"));
}
})();
}}
>
</Button>
)}
{readCount > 0 && (
<ConfirmActionButton
variant="ghost"
title="清空已读消息?"
description="已读消息会从列表中移除,未读消息会继续保留。"
confirmLabel="清空已读"
successMessage="已读消息已清空"
errorMessage="操作失败"
onConfirm={removeReadNotifications}
>
</ConfirmActionButton>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { NotificationLevel } from "@prisma/client";
import type { StatusTone } from "@/components/shared/status-badge";
export const notificationLevelLabels: Record<NotificationLevel, string> = {
INFO: "默认",
SUCCESS: "成功",
WARNING: "提醒",
ERROR: "异常",
};
export function getNotificationLevelTone(level: NotificationLevel): StatusTone {
if (level === "SUCCESS") return "success";
if (level === "WARNING") return "warning";
if (level === "ERROR") return "danger";
return "neutral";
}
export function getNotificationReadTone(isRead: boolean): StatusTone {
return isRead ? "neutral" : "info";
}
export function getNotificationReadLabel(isRead: boolean) {
return isRead ? "已读" : "未读";
}

View File

@@ -0,0 +1,28 @@
import type { UserNotification } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export interface UserNotificationsData {
notifications: UserNotification[];
unreadCount: number;
readCount: number;
}
export async function getUserNotifications(userId: string): Promise<UserNotificationsData> {
const notifications = await prisma.userNotification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 100,
});
const unreadCount = notifications.filter((notification) => !notification.isRead).length;
const readCount = notifications.length - unreadCount;
return { notifications, unreadCount, readCount };
}
export async function getUnreadNotificationCount(userId: string): Promise<number> {
return prisma.userNotification.count({
where: { userId, isRead: false },
});
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { NotificationBulkAction } from "./notification-actions";
import { NotificationList } from "./_components/notification-list";
import { getUserNotifications } from "./notifications-data";
export const metadata: Metadata = {
title: "通知中心",
description: "集中查看支付、订阅与系统通知。",
};
export default async function NotificationsPage() {
const session = await getServerSession(authOptions);
const { notifications, unreadCount, readCount } = await getUserNotifications(session!.user.id);
return (
<PageShell>
<PageHeader
eyebrow="消息中心"
title="通知与提醒"
actions={
unreadCount > 0 || readCount > 0 ? (
<NotificationBulkAction unreadCount={unreadCount} readCount={readCount} />
) : null
}
/>
<NotificationList notifications={notifications} unreadCount={unreadCount} />
</PageShell>
);
}

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import { ShoppingBag } from "lucide-react";
import { DataTableShell } from "@/components/shared/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import { OrderStatusBadge } from "@/components/shared/domain-badges";
import { buttonVariants } from "@/components/ui/button";
import { formatDateShort } from "@/lib/utils";
import { UserOrderActions } from "../order-actions";
import {
formatOrderAmount,
formatOrderTraffic,
orderKindLabels,
} from "../orders-calculations";
import type { UserOrderRow } from "../orders-data";
interface UserOrdersTableProps {
orders: UserOrderRow[];
}
export function UserOrdersTable({ orders }: UserOrdersTableProps) {
return (
<DataTableShell
isEmpty={orders.length === 0}
emptyTitle="还没有订单"
emptyDescription="选好套餐并提交支付后,你可以在这里继续支付、查看状态和回看记录。"
emptyIcon={<ShoppingBag className="size-5" />}
emptyAction={
<Link href="/store" className={buttonVariants()}>
</Link>
}
>
<DataTable aria-label="我的订单列表" className="min-w-[780px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{orders.map((order) => (
<DataTableRow key={order.id}>
<DataTableCell className="font-medium text-foreground">{order.plan.name}</DataTableCell>
<DataTableCell className="text-muted-foreground">{orderKindLabels[order.kind]}</DataTableCell>
<DataTableCell className="text-muted-foreground">
{formatOrderTraffic(order.trafficGb)}
</DataTableCell>
<DataTableCell className="tabular-nums">{formatOrderAmount(order.amount)}</DataTableCell>
<DataTableCell>
<OrderStatusBadge status={order.status} />
</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDateShort(order.createdAt)}
</DataTableCell>
<DataTableCell>
<div className="flex items-center justify-end gap-3">
{order.status === "PENDING" && (
<Link
href={`/pay/${order.id}`}
className="text-sm font-medium text-primary hover:underline"
>
</Link>
)}
<UserOrderActions orderId={order.id} status={order.status} />
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { cancelOwnPendingOrder } from "@/actions/user/orders";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
export function UserOrderActions({
orderId,
status,
}: {
orderId: string;
status: "PENDING" | "PAID" | "CANCELLED" | "REFUNDED";
}) {
if (status !== "PENDING") {
return null;
}
return (
<ConfirmActionButton
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
title="取消这笔订单?"
description="取消后会释放当前保留的名额,你可以重新选择套餐或支付方式。"
confirmLabel="取消订单"
successMessage="订单已取消"
errorMessage="取消订单失败"
onConfirm={() => cancelOwnPendingOrder(orderId)}
>
</ConfirmActionButton>
);
}

View File

@@ -0,0 +1,17 @@
import type { OrderStatus } from "@prisma/client";
import type { StatusTone } from "@/components/shared/status-badge";
export {
getOrderStatusTone,
orderKindLabels,
orderStatusLabels,
} from "@/components/shared/domain-badges";
export function formatOrderTraffic(trafficGb: number | null) {
return trafficGb === null ? "—" : `${trafficGb} GB`;
}
export function formatOrderAmount(amount: { toString(): string } | number | string) {
return `¥${Number(amount).toFixed(2)}`;
}
export type { OrderStatus, StatusTone };

View File

@@ -0,0 +1,35 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
const userOrderInclude = {
plan: true,
} satisfies Prisma.OrderInclude;
export type UserOrderRow = Prisma.OrderGetPayload<{
include: typeof userOrderInclude;
}>;
export async function getUserOrders({
userId,
searchParams,
}: {
userId: string;
searchParams: Record<string, string | string[] | undefined>;
}) {
const { page, skip, pageSize } = parsePage(searchParams);
const where = { userId } satisfies Prisma.OrderWhereInput;
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: userOrderInclude,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.order.count({ where }),
]);
return { orders, total, page, pageSize };
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { Pagination } from "@/components/shared/pagination";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { UserOrdersTable } from "./_components/user-orders-table";
import { getUserOrders } from "./orders-data";
export const metadata: Metadata = {
title: "我的订单",
description: "查看新购、续费和增流量订单记录。",
};
export default async function UserOrdersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await getServerSession(authOptions);
const { orders, total, page, pageSize } = await getUserOrders({
userId: session!.user.id,
searchParams: await searchParams,
});
return (
<PageShell>
<PageHeader
eyebrow="订单记录"
title="我的订单"
/>
<UserOrdersTable orders={orders} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

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

View File

@@ -0,0 +1,10 @@
import type { TrafficLog } from "@prisma/client";
import { TrafficLogList, TrafficLogListSkeleton } from "@/components/subscriptions/traffic-log-list";
export function TrafficLogs({ logs }: { logs: TrafficLog[] }) {
return <TrafficLogList logs={logs} />;
}
export function TrafficLogsSkeleton() {
return <TrafficLogListSkeleton />;
}

View File

@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
import { TrafficLogs } from "./_components/traffic-logs";
import { getSiteBaseUrl } from "@/services/site-url";
import { ProxySubscriptionDetails } from "../_components/proxy-subscription-details";
import { StreamingCredentialCard } from "../streaming-credential-card";
import { getUserSubscriptionDetail } from "./subscription-detail-data";
export const metadata: Metadata = {
title: "订阅详情",
description: "查看订阅详情、流量记录与生命周期事件。",
};
export default async function UserSubscriptionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getServerSession(authOptions);
const { id } = await params;
const requestHeaders = await headers();
const [data, baseUrl] = await Promise.all([
getUserSubscriptionDetail({
subscriptionId: id,
userId: session!.user.id,
}),
getSiteBaseUrl({ headers: requestHeaders }),
]);
if (!data) {
notFound();
}
const { subscription, auditLogs, trafficLogs } = data;
return (
<PageShell>
<PageHeader
eyebrow="订阅详情"
title={subscription.plan.name}
/>
<SubscriptionDetailCards subscription={subscription} />
{subscription.plan.type === "PROXY" && (
<section className="surface-card space-y-4 rounded-xl p-5">
<SectionHeader
title="导入与二维码"
description="单节点链接保留在详情页;日常使用建议导入订阅页的总订阅链接。"
/>
<ProxySubscriptionDetails sub={subscription} baseUrl={baseUrl} />
</section>
)}
{subscription.plan.type === "STREAMING" && subscription.streamingSlot && (
<section className="surface-card space-y-4 rounded-xl p-5">
<SectionHeader
title="账号凭据"
description="只在需要时展开共享账号信息。"
/>
<StreamingCredentialCard subscriptionId={subscription.id} />
</section>
)}
<SubscriptionTimelineSection logs={auditLogs} />
{subscription.nodeClient && <TrafficLogs logs={trafficLogs} />}
</PageShell>
);
}

View File

@@ -0,0 +1,64 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const userSubscriptionDetailInclude = {
plan: { include: { node: true, category: true } },
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
streamingSlot: {
include: {
service: true,
},
},
} satisfies Prisma.UserSubscriptionInclude;
export type UserSubscriptionDetail = Prisma.UserSubscriptionGetPayload<{
include: typeof userSubscriptionDetailInclude;
}>;
export async function getUserSubscriptionDetail({
subscriptionId,
userId,
}: {
subscriptionId: string;
userId: string;
}) {
const subscription = await prisma.userSubscription.findFirst({
where: {
id: subscriptionId,
userId,
},
include: userSubscriptionDetailInclude,
});
if (!subscription) {
return null;
}
const [auditLogs, trafficLogs] = await Promise.all([
prisma.auditLog.findMany({
where: {
targetType: "UserSubscription",
targetId: subscription.id,
},
orderBy: { createdAt: "desc" },
take: 50,
}),
subscription.nodeClient
? prisma.trafficLog.findMany({
where: { clientId: subscription.nodeClient.id },
orderBy: { timestamp: "desc" },
take: 20,
})
: [],
]);
return { subscription, auditLogs, trafficLogs };
}

View File

@@ -0,0 +1,155 @@
import Link from "next/link";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { ArrowUpRight, CalendarClock, Database, Radio, Server, Tv } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/shared/status-badge";
import { formatBytes } from "@/lib/utils";
import { SubscriptionActions } from "../subscription-actions";
import {
getPlanTypeLabel,
getPlanTypeTone,
getTrafficPoolRemainingGb,
} from "../subscriptions-calculations";
import type { SubscriptionRecord } from "../subscriptions-types";
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
interface ActiveSubscriptionCardProps {
sub: SubscriptionRecord;
poolMap: Map<string, PlanTrafficPoolState>;
}
function getInboundDisplayName(sub: SubscriptionRecord) {
const settings = sub.nodeClient?.inbound.settings;
if (settings && typeof settings === "object" && "displayName" in settings) {
const value = (settings as { displayName?: unknown }).displayName;
if (typeof value === "string" && value.trim()) return value.trim();
}
return sub.nodeClient?.inbound.tag ?? "等待分配";
}
function ProxyCompactSummary({ sub }: { sub: SubscriptionRecord }) {
if (sub.plan.type !== "PROXY") return null;
const used = Number(sub.trafficUsed);
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
const nodeName = sub.nodeClient?.inbound.server.name ?? sub.plan.node?.name ?? "待分配";
const inboundName = getInboundDisplayName(sub);
const protocol = sub.nodeClient?.inbound.protocol ?? "—";
const port = sub.nodeClient?.inbound.port ?? null;
return (
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-3">
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="rounded-lg bg-background/70 px-3 py-2">
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
<Server className="size-3.5 text-primary" />
</p>
<p className="mt-1 truncate font-semibold">{nodeName}</p>
</div>
<div className="rounded-lg bg-background/70 px-3 py-2">
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
<Radio className="size-3.5 text-primary" />
</p>
<p className="mt-1 truncate font-semibold">{protocol}{port ? ` · ${port}` : ""}</p>
</div>
</div>
<div className="rounded-lg bg-background/70 px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="inline-flex min-w-0 items-center gap-1.5 text-muted-foreground">
<Database className="size-3.5 text-primary" />
<span className="truncate">{inboundName}</span>
</span>
{limit && <span className="font-semibold text-primary tabular-nums">{percent}%</span>}
</div>
{limit ? (
<>
<Progress value={percent} />
<p className="mt-2 text-xs text-muted-foreground">
{formatBytes(used)} / {formatBytes(limit)}
</p>
</>
) : (
<p className="text-xs text-muted-foreground"> · {formatBytes(used)}</p>
)}
</div>
</div>
);
}
function StreamingCompactSummary({ sub }: { sub: SubscriptionRecord }) {
if (sub.plan.type !== "STREAMING") return null;
return (
<div className="rounded-xl border border-border bg-muted/25 p-3">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Tv className="size-3.5 text-primary" />
</p>
<p className="mt-1 text-sm font-semibold">{sub.streamingSlot?.service.name ?? "账号分配中"}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">使</p>
</div>
);
}
export function ActiveSubscriptionCard({ sub, poolMap }: ActiveSubscriptionCardProps) {
return (
<Card className="group overflow-hidden transition-colors duration-200 hover:border-primary/25 hover:bg-muted/10">
<CardHeader className="gap-3 p-4 pb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{sub.plan.type === "PROXY" ? <Radio className="size-4" /> : <Tv className="size-4" />}
</span>
<div className="min-w-0 space-y-1.5">
<CardTitle className="text-base">
<Link
href={`/subscriptions/${sub.id}`}
className="group/link inline-flex max-w-full items-center gap-1.5 hover:text-primary"
>
<span className="truncate">{sub.plan.name}</span>
<ArrowUpRight className="size-3.5 opacity-45 transition-transform duration-300 group-hover/link:-translate-y-0.5 group-hover/link:translate-x-0.5 group-hover/link:opacity-100" />
</Link>
</CardTitle>
<p className="inline-flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<CalendarClock className="size-3.5" />
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })}
</p>
</div>
</div>
<StatusBadge tone={getPlanTypeTone(sub.plan.type)} className="h-6 px-2 text-[11px]">
{getPlanTypeLabel(sub.plan.type)}
</StatusBadge>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-1">
<ProxyCompactSummary sub={sub} />
<StreamingCompactSummary sub={sub} />
<SubscriptionActions
subscriptionId={sub.id}
type={sub.plan.type}
allowRenewal={sub.plan.allowRenewal}
allowTrafficTopup={sub.plan.type === "PROXY" && sub.plan.allowTrafficTopup}
trafficPoolRemainingGb={getTrafficPoolRemainingGb(sub, poolMap)}
renewalConfig={{
durationDays: sub.plan.durationDays,
renewalPrice: sub.plan.renewalPrice == null ? null : Number(sub.plan.renewalPrice),
renewalPricingMode: sub.plan.renewalPricingMode,
renewalDurationDays: sub.plan.renewalDurationDays,
renewalMinDays: sub.plan.renewalMinDays,
renewalMaxDays: sub.plan.renewalMaxDays,
}}
topupConfig={{
topupPricingMode: sub.plan.topupPricingMode,
topupPricePerGb: sub.plan.topupPricePerGb == null ? null : Number(sub.plan.topupPricePerGb),
topupFixedPrice: sub.plan.topupFixedPrice == null ? null : Number(sub.plan.topupFixedPrice),
minTopupGb: sub.plan.minTopupGb,
maxTopupGb: sub.plan.maxTopupGb,
}}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
import Link from "next/link";
import { Film, Radio, ShoppingBag } from "lucide-react";
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
import { buttonVariants } from "@/components/ui/button";
import { CollapsibleGroup } from "@/components/shared/collapsible-group";
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
import type { SubscriptionRecord } from "../subscriptions-types";
import { ActiveSubscriptionCard } from "./active-subscription-card";
import { AggregateSubscriptionCard } from "./aggregate-subscription-card";
interface ActiveSubscriptionsSectionProps {
subscriptions: SubscriptionRecord[];
aggregateSubscriptionUrl: string | null;
poolMap: Map<string, PlanTrafficPoolState>;
}
function toBigInt(value: bigint | number | null | undefined) {
if (typeof value === "bigint") return value;
return BigInt(value ?? 0);
}
function groupSubscriptions(subscriptions: SubscriptionRecord[], type: "PROXY" | "STREAMING") {
const groups = new Map<string, { title: string; subtitle: string; items: SubscriptionRecord[] }>();
for (const sub of subscriptions.filter((item) => item.plan.type === type)) {
const key = sub.plan.category?.id ?? `default-${type}`;
const fallbackTitle = type === "PROXY" ? "代理连接" : "流媒体共享";
const group = groups.get(key) ?? {
title: sub.plan.category?.name ?? fallbackTitle,
subtitle: "",
items: [],
};
group.items.push(sub);
groups.set(key, group);
}
return Array.from(groups.values()).map((g) => ({
...g,
subtitle: `${g.items.length} 个订阅`,
}));
}
function getProxySummary(subscriptions: SubscriptionRecord[]) {
const proxySubscriptions = subscriptions.filter((sub) => sub.plan.type === "PROXY" && sub.nodeClient);
let totalUsed = BigInt(0);
let totalLimit = BigInt(0);
let hasUnlimited = false;
let nextExpiry: Date | null = null;
for (const sub of proxySubscriptions) {
totalUsed += toBigInt(sub.trafficUsed);
if (sub.trafficLimit == null) {
hasUnlimited = true;
} else {
totalLimit += toBigInt(sub.trafficLimit);
}
if (!nextExpiry || sub.endDate < nextExpiry) {
nextExpiry = sub.endDate;
}
}
return {
nodeCount: proxySubscriptions.length,
totalUsed,
totalLimit: hasUnlimited ? null : totalLimit,
nextExpiry,
};
}
export function ActiveSubscriptionsSection({
subscriptions,
aggregateSubscriptionUrl,
poolMap,
}: ActiveSubscriptionsSectionProps) {
const proxyGroups = groupSubscriptions(subscriptions, "PROXY");
const streamingGroups = groupSubscriptions(subscriptions, "STREAMING");
const proxySummary = getProxySummary(subscriptions);
return (
<section className="space-y-5">
<SectionHeader title="活跃订阅" />
{subscriptions.length === 0 ? (
<EmptyState
eyebrow="下一步"
icon={<ShoppingBag className="size-5" />}
title="还没有正在使用的订阅"
description="选择套餐并完成支付后,这里会显示统一订阅链接、节点概览和续费入口。"
action={
<Link href="/store" className={buttonVariants()}>
</Link>
}
/>
) : (
<div className="space-y-7">
{proxySummary.nodeCount > 0 && aggregateSubscriptionUrl && (
<AggregateSubscriptionCard
subscriptionUrl={aggregateSubscriptionUrl}
nodeCount={proxySummary.nodeCount}
totalUsed={proxySummary.totalUsed}
totalLimit={proxySummary.totalLimit}
nextExpiry={proxySummary.nextExpiry}
/>
)}
{proxyGroups.length > 0 && (
<div className="space-y-4">
<SectionHeader
title="节点概览"
description="节点卡片只保留状态、流量和操作;配置、二维码和日志放到详情页。"
actions={<Radio className="size-5 text-primary" />}
/>
{proxyGroups.map((group, index) => (
<CollapsibleGroup
key={group.title}
title={group.title}
subtitle={group.subtitle}
defaultOpen={index === 0}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{group.items.map((sub) => (
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
))}
</div>
</CollapsibleGroup>
))}
</div>
)}
{streamingGroups.length > 0 && (
<div className="space-y-4">
<SectionHeader
title="流媒体服务"
description="账号信息已收进详情页,列表只显示可用状态。"
actions={<Film className="size-5 text-primary" />}
/>
{streamingGroups.map((group, index) => (
<CollapsibleGroup
key={group.title}
title={group.title}
subtitle={group.subtitle}
defaultOpen={index === 0}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{group.items.map((sub) => (
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
))}
</div>
</CollapsibleGroup>
))}
</div>
)}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,102 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { QrPreview } from "@/components/shared/qr-preview";
import { Progress } from "@/components/ui/progress";
import { formatBytes } from "@/lib/utils";
interface AggregateSubscriptionCardProps {
subscriptionUrl: string;
nodeCount: number;
totalUsed: bigint;
totalLimit: bigint | null;
nextExpiry: Date | null;
}
export function AggregateSubscriptionCard({
subscriptionUrl,
nodeCount,
totalUsed,
totalLimit,
nextExpiry,
}: AggregateSubscriptionCardProps) {
const percent = totalLimit
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
: 0;
return (
<section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6">
<div className="absolute right-8 top-8 size-32 rounded-full bg-primary/10 blur-3xl" />
<div className="relative grid gap-6 lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start">
<div className="space-y-5">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<Sparkles className="size-3.5" />
</div>
<div className="max-w-2xl space-y-2">
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-balance sm:text-3xl">
</h2>
<p className="text-sm leading-6 text-muted-foreground text-pretty">
</p>
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/80">{subscriptionUrl}</p>
</div>
<CopyButton text={subscriptionUrl} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Layers3 className="size-3.5 text-primary" />
</p>
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{nodeCount}</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Gauge className="size-3.5 text-primary" />
</p>
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{formatBytes(totalUsed)}</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CalendarClock className="size-3.5 text-primary" />
</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
{nextExpiry ? format(nextExpiry, "MM-dd HH:mm", { locale: zhCN }) : "—"}
</p>
</div>
</div>
{totalLimit && (
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-primary tabular-nums">{percent}%</span>
</div>
<Progress value={percent} />
<p className="mt-2 text-xs text-muted-foreground">
{formatBytes(totalLimit)} · {formatBytes(totalLimit - totalUsed > BigInt(0) ? totalLimit - totalUsed : BigInt(0))}
</p>
</div>
)}
</div>
<div className="lg:sticky lg:top-24">
<QrPreview label="总订阅二维码" value={subscriptionUrl} alt="总订阅链接二维码" />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,49 @@
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { Archive } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/shared/status-badge";
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
import {
getSubscriptionStatusLabel,
getSubscriptionStatusTone,
} from "../subscriptions-calculations";
import type { SubscriptionRecord } from "../subscriptions-types";
export function HistorySubscriptionsSection({
subscriptions,
}: {
subscriptions: SubscriptionRecord[];
}) {
return (
<section className="space-y-4">
<SectionHeader title="历史记录" />
{subscriptions.length === 0 ? (
<EmptyState
icon={<Archive className="size-5" />}
title="历史记录还是空的"
description="过期、暂停或取消后的订阅会在这里保留记录,方便你之后回看。"
/>
) : (
<div className="space-y-2">
{subscriptions.map((sub) => (
<Card key={sub.id} className="border-muted-foreground/15">
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-medium">{sub.plan.name}</p>
<p className="text-xs text-muted-foreground">
{format(sub.startDate, "yyyy-MM-dd", { locale: zhCN })} ~{" "}
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })}
</p>
</div>
<StatusBadge tone={getSubscriptionStatusTone(sub.status)}>
{getSubscriptionStatusLabel(sub.status)}
</StatusBadge>
</CardContent>
</Card>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,76 @@
import { Gauge, Link2, QrCode } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { CopyButton } from "@/components/shared/copy-button";
import { QrPreview } from "@/components/shared/qr-preview";
import { formatBytes } from "@/lib/utils";
import { buildSingleNodeUri } from "@/services/subscription";
import type { SubscriptionRecord } from "../subscriptions-types";
interface ProxySubscriptionDetailsProps {
sub: SubscriptionRecord;
baseUrl: string;
}
export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDetailsProps) {
if (sub.plan.type !== "PROXY") return null;
const used = Number(sub.trafficUsed);
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
return (
<div className="space-y-4">
{limit && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Gauge className="size-4 text-primary" />
</div>
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary tabular-nums">
{percent}%
</span>
</div>
<Progress value={percent} className="h-2.5" />
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<p className="rounded-xl bg-background/45 px-3 py-2"> <span className="font-semibold text-foreground">{formatBytes(used)}</span></p>
<p className="rounded-xl bg-background/45 px-3 py-2"> <span className="font-semibold text-foreground">{formatBytes(Math.max(0, limit - used))}</span></p>
</div>
</div>
)}
{sub.nodeClient ? (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4 text-primary" />
</div>
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/45 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
</div>
<CopyButton text={subUrl} />
</div>
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<QrCode className="size-3.5" />
</div>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<QrPreview label="订阅 URL 二维码" value={subUrl} alt="订阅 URL 二维码" />
{singleNodeUri && (
<QrPreview
label="单节点 URI 二维码"
value={singleNodeUri}
alt="单节点 URI 二维码"
/>
)}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-3 text-sm leading-6 text-muted-foreground">
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw, WalletCards } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { purchaseRenewal } from "@/actions/user/purchase";
import { getErrorMessage } from "@/lib/errors";
interface RenewalConfig {
durationDays: number;
renewalPrice: number | null;
renewalPricingMode: string;
renewalDurationDays: number | null;
renewalMinDays: number | null;
renewalMaxDays: number | null;
}
function clampDuration(value: number, min: number, max: number, step: number) {
const clamped = Math.min(max, Math.max(min, value));
if (step <= 1) return clamped;
const offset = clamped - min;
return min + Math.round(offset / step) * step;
}
export function RenewalButton({
subscriptionId,
config,
}: {
subscriptionId: string;
config: RenewalConfig;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const isPerDay = config.renewalPricingMode === "PER_DAY";
const unitDays = isPerDay ? 1 : (config.renewalDurationDays ?? config.durationDays);
const minDays = config.renewalMinDays ?? unitDays;
const maxDays = Math.max(minDays, config.renewalMaxDays ?? unitDays);
const step = isPerDay ? 1 : unitDays;
const [renewalDays, setRenewalDays] = useState(minDays);
const totalPrice = useMemo(() => {
const unitPrice = config.renewalPrice ?? 0;
const amount = isPerDay ? unitPrice * renewalDays : unitPrice * Math.max(1, renewalDays / unitDays);
return amount.toFixed(2);
}, [config.renewalPrice, isPerDay, renewalDays, unitDays]);
async function handleRenewal() {
setLoading(true);
try {
const result = await purchaseRenewal(subscriptionId, renewalDays);
if (!result.ok) {
toast.error(result.error);
return;
}
router.push(`/pay/${result.orderId}`);
} catch (error) {
toast.error(getErrorMessage(error, "创建续费订单失败"));
} finally {
setLoading(false);
}
}
return (
<>
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => {
setRenewalDays(minDays);
setOpen(true);
}}
className="flex-1 sm:flex-none"
>
<RefreshCw className="size-3.5" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
<WalletCards className="size-3.5" /> RENEWAL
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-4 flex justify-between text-sm">
<span className="font-medium"></span>
<span className="font-semibold text-primary tabular-nums">{renewalDays} </span>
</div>
<Slider
value={[renewalDays]}
onValueChange={(values: number | readonly number[]) => {
const value = Array.isArray(values) ? values[0] : values;
setRenewalDays(clampDuration(value ?? minDays, minDays, maxDays, step));
}}
min={minDays}
max={maxDays}
step={step}
disabled={maxDays <= minDays}
/>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{minDays} </span>
<span>{isPerDay ? `¥${(config.renewalPrice ?? 0).toFixed(2)}/天` : `¥${(config.renewalPrice ?? 0).toFixed(2)}/${unitDays}`}</span>
<span>{maxDays} </span>
</div>
</div>
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium"></span>
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
size="lg"
className="w-full sm:w-auto"
onClick={handleRenewal}
disabled={loading || !config.renewalPrice}
>
{loading ? "创建中..." : "去支付"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useRouter } from "next/navigation";
import { ShieldCheck } from "lucide-react";
import { rotateSubscriptionAccess } from "@/actions/user/subscription-security";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
export function ResetAccessButton({ subscriptionId }: { subscriptionId: string }) {
const router = useRouter();
return (
<ConfirmActionButton
size="sm"
variant="outline"
className="flex-1 sm:flex-none"
title="重置订阅访问?"
description="我们会为这条订阅生成新的访问凭据。旧链接会失效,请在客户端重新导入。"
confirmLabel="重置访问"
successMessage="订阅访问已重置"
errorMessage="重置失败"
onConfirm={() => rotateSubscriptionAccess(subscriptionId)}
onSuccess={() => router.refresh()}
>
<ShieldCheck className="size-3.5" />
访
</ConfirmActionButton>
);
}

View File

@@ -0,0 +1,25 @@
import { Tv } from "lucide-react";
import type { SubscriptionRecord } from "../subscriptions-types";
import { StreamingCredentialCard } from "../streaming-credential-card";
export function StreamingSubscriptionDetails({ sub }: { sub: SubscriptionRecord }) {
if (sub.plan.type !== "STREAMING") return null;
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Tv className="size-4 text-primary" />
</div>
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary">
{sub.streamingSlot?.service.name ?? "待分配"}
</span>
</div>
{sub.streamingSlot ? (
<StreamingCredentialCard subscriptionId={sub.id} />
) : (
<p className="text-sm leading-6 text-muted-foreground"></p>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
interface SubscriptionMetricsProps {
activeCount: number;
historyCount: number;
totalCount: number;
}
const metrics = [
["活跃", "activeCount"],
["历史", "historyCount"],
["全部", "totalCount"],
] as const;
export function SubscriptionMetrics({
activeCount,
historyCount,
totalCount,
}: SubscriptionMetricsProps) {
const values = { activeCount, historyCount, totalCount };
return (
<section className="grid gap-2 sm:grid-cols-3">
{metrics.map(([label, key]) => (
<div key={key} className="flex items-center justify-between rounded-xl border border-border bg-muted/20 px-4 py-3">
<span className="text-xs font-medium tracking-wide text-muted-foreground">{label}</span>
<span className="text-lg font-semibold tracking-[-0.04em] tabular-nums">{values[key]}</span>
</div>
))}
</section>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, WalletCards } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { purchaseTrafficTopup } from "@/actions/user/purchase";
import { getErrorMessage } from "@/lib/errors";
interface TrafficTopupConfig {
topupPricingMode: string;
topupPricePerGb: number | null;
topupFixedPrice: number | null;
minTopupGb: number | null;
maxTopupGb: number | null;
}
interface TrafficTopupDialogProps {
subscriptionId: string;
trafficPoolRemainingGb: number | null;
config: TrafficTopupConfig;
}
function getTopupBounds(trafficPoolRemainingGb: number | null, config: TrafficTopupConfig) {
const min = Math.max(1, config.minTopupGb ?? 1);
const configuredMax = config.maxTopupGb ?? 1000;
const poolMax = trafficPoolRemainingGb == null
? configuredMax
: Math.max(0, Math.floor(trafficPoolRemainingGb));
const max = Math.max(0, Math.min(configuredMax, poolMax));
return { min, max };
}
export function TrafficTopupDialog({
subscriptionId,
trafficPoolRemainingGb,
config,
}: TrafficTopupDialogProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { min, max } = getTopupBounds(trafficPoolRemainingGb, config);
const initialTopupGb = useMemo(() => {
if (max <= 0) return min;
return Math.min(Math.max(min, 10), max);
}, [max, min]);
const [topupGb, setTopupGb] = useState(initialTopupGb);
const isFixedAmount = config.topupPricingMode === "FIXED_AMOUNT";
const totalPrice = useMemo(() => {
const amount = isFixedAmount
? (config.topupFixedPrice ?? 0)
: (config.topupPricePerGb ?? 0) * topupGb;
return amount.toFixed(2);
}, [config.topupFixedPrice, config.topupPricePerGb, isFixedAmount, topupGb]);
const hasPrice = isFixedAmount
? (config.topupFixedPrice ?? 0) > 0
: (config.topupPricePerGb ?? 0) > 0;
const disabled = max <= 0 || max < min || !hasPrice;
async function handleTopup() {
if (disabled) {
toast.error(hasPrice ? "当前套餐剩余额度不足,暂不可增流量" : "这款套餐暂未配置增流量价格");
return;
}
setLoading(true);
try {
const orderId = await purchaseTrafficTopup(subscriptionId, topupGb);
router.push(`/pay/${orderId}`);
} catch (error) {
toast.error(getErrorMessage(error, "创建增流量订单失败"));
} finally {
setLoading(false);
}
}
return (
<>
<Button
size="sm"
onClick={() => {
setTopupGb(initialTopupGb);
setOpen(true);
}}
disabled={disabled}
className="flex-1 sm:flex-none"
>
<Plus className="size-3.5" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
<WalletCards className="size-3.5" /> TRAFFIC TOPUP
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-semibold tabular-nums">{disabled ? "暂无额度" : `${min}-${max} GB`}</span>
</div>
</div>
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-4 flex justify-between text-sm">
<span className="font-medium"></span>
<span className="font-semibold text-primary tabular-nums">{topupGb} GB</span>
</div>
<Slider
value={[topupGb]}
onValueChange={(values: number | readonly number[]) => {
const value = Array.isArray(values) ? values[0] : values;
setTopupGb(value ?? min);
}}
min={min}
max={Math.max(min, max)}
step={1}
disabled={disabled}
/>
<p className="mt-3 text-xs text-muted-foreground">
{isFixedAmount
? `固定金额 ¥${(config.topupFixedPrice ?? 0).toFixed(2)}`
: `按 ¥${(config.topupPricePerGb ?? 0).toFixed(2)}/GB 计费`}
</p>
</div>
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium"></span>
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
size="lg"
className="w-full sm:w-auto"
onClick={handleTopup}
disabled={loading || disabled}
>
{loading ? "创建中..." : "去支付"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import {
getActiveSubscriptions,
getHistorySubscriptions,
} from "./subscriptions-calculations";
import {
getSubscriptionBaseUrl,
getTrafficPoolMap,
getUserSubscriptions,
} from "./subscriptions-data";
import { ActiveSubscriptionsSection } from "./_components/active-subscriptions-section";
import { HistorySubscriptionsSection } from "./_components/history-subscriptions-section";
import { SubscriptionMetrics } from "./_components/subscription-metrics";
import { getAggregateSubscriptionToken } from "@/services/subscription";
export const metadata: Metadata = {
title: "我的订阅",
description: "管理活跃订阅并查看历史记录。",
};
export default async function SubscriptionsPage() {
const session = await getServerSession(authOptions);
const [subs, baseUrl] = await Promise.all([
getUserSubscriptions(session!.user.id),
getSubscriptionBaseUrl(),
]);
const activeSubs = getActiveSubscriptions(subs);
const historySubs = getHistorySubscriptions(subs);
const poolMap = await getTrafficPoolMap(subs);
const aggregateSubscriptionUrl = baseUrl
? `${baseUrl}/api/subscription/all?userId=${encodeURIComponent(session!.user.id)}&token=${encodeURIComponent(getAggregateSubscriptionToken(session!.user.id))}`
: null;
return (
<PageShell>
<PageHeader
eyebrow="订阅管理"
title="我的订阅"
description="总订阅链接负责导入全部代理节点;单个节点卡片只保留状态和快捷操作。"
/>
<SubscriptionMetrics
activeCount={activeSubs.length}
historyCount={historySubs.length}
totalCount={subs.length}
/>
<ActiveSubscriptionsSection
subscriptions={activeSubs}
aggregateSubscriptionUrl={aggregateSubscriptionUrl}
poolMap={poolMap}
/>
<HistorySubscriptionsSection subscriptions={historySubs} />
</PageShell>
);
}

View File

@@ -0,0 +1,40 @@
"use server";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
export async function revealStreamingCredential(subscriptionId: string) {
const session = await requireAuth();
const subscription = await prisma.userSubscription.findFirst({
where: {
id: subscriptionId,
userId: session.user.id,
status: "ACTIVE",
},
select: {
streamingSlot: {
select: {
service: {
select: {
credentials: true,
description: true,
name: true,
},
},
},
},
},
});
const service = subscription?.streamingSlot?.service;
if (!service) {
throw new Error("当前订阅没有可用的流媒体凭据");
}
return {
name: service.name,
description: service.description,
credentials: decrypt(service.credentials),
};
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { KeyRound } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { Button } from "@/components/ui/button";
import { getErrorMessage } from "@/lib/errors";
import { revealStreamingCredential } from "./streaming-credential-action";
export function StreamingCredentialCard({
subscriptionId,
}: {
subscriptionId: string;
}) {
const [loading, setLoading] = useState(false);
const [credential, setCredential] = useState<{
name: string;
description: string | null;
credentials: string;
} | null>(null);
async function handleReveal() {
setLoading(true);
try {
const result = await revealStreamingCredential(subscriptionId);
setCredential(result);
} catch (error) {
setCredential({
name: "获取失败",
description: null,
credentials: getErrorMessage(error, "无法读取凭据"),
});
} finally {
setLoading(false);
}
}
return (
<div className="space-y-3 rounded-lg border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<KeyRound className="size-4 text-primary" />
</div>
{!credential && (
<Button size="sm" variant="outline" onClick={() => void handleReveal()} disabled={loading}>
{loading ? "读取中..." : "查看凭据"}
</Button>
)}
</div>
{credential ? (
<>
{credential.description && (
<p className="text-xs leading-5 text-muted-foreground">{credential.description}</p>
)}
<div className="rounded-xl border border-border bg-muted/25 p-3 font-mono text-sm whitespace-pre-wrap break-words">
{credential.credentials}
</div>
<CopyButton text={credential.credentials} />
</>
) : (
<p className="text-xs leading-5 text-muted-foreground">
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { RenewalButton } from "./_components/renewal-button";
import { ResetAccessButton } from "./_components/reset-access-button";
import { TrafficTopupDialog } from "./_components/traffic-topup-dialog";
interface RenewalConfig {
durationDays: number;
renewalPrice: number | null;
renewalPricingMode: string;
renewalDurationDays: number | null;
renewalMinDays: number | null;
renewalMaxDays: number | null;
}
interface TopupConfig {
topupPricingMode: string;
topupPricePerGb: number | null;
topupFixedPrice: number | null;
minTopupGb: number | null;
maxTopupGb: number | null;
}
interface Props {
subscriptionId: string;
type: "PROXY" | "STREAMING";
allowRenewal: boolean;
allowTrafficTopup: boolean;
trafficPoolRemainingGb: number | null;
renewalConfig: RenewalConfig;
topupConfig: TopupConfig;
}
export function SubscriptionActions({
subscriptionId,
type,
allowRenewal,
allowTrafficTopup,
trafficPoolRemainingGb,
renewalConfig,
topupConfig,
}: Props) {
return (
<div className="flex flex-wrap gap-2 rounded-lg border border-border bg-background p-2">
<Link
href={`/subscriptions/${subscriptionId}`}
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex-1 sm:flex-none")}
>
<ArrowUpRight className="size-3.5" />
</Link>
{type === "PROXY" && <ResetAccessButton subscriptionId={subscriptionId} />}
{allowRenewal && <RenewalButton subscriptionId={subscriptionId} config={renewalConfig} />}
{allowTrafficTopup && (
<TrafficTopupDialog
subscriptionId={subscriptionId}
trafficPoolRemainingGb={trafficPoolRemainingGb}
config={topupConfig}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { StatusTone } from "@/components/shared/status-badge";
import {
getSubscriptionStatusTone as getDomainSubscriptionStatusTone,
getSubscriptionTypeTone,
subscriptionStatusLabels,
subscriptionTypeLabels,
} from "@/components/shared/domain-badges";
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
import type { SubscriptionRecord } from "./subscriptions-types";
export function getActiveSubscriptions(subscriptions: SubscriptionRecord[]) {
return subscriptions.filter((sub) => sub.status === "ACTIVE");
}
export function getHistorySubscriptions(subscriptions: SubscriptionRecord[]) {
return subscriptions.filter((sub) => sub.status !== "ACTIVE");
}
export function getProxyPlanIds(subscriptions: SubscriptionRecord[]) {
return Array.from(
new Set(
subscriptions
.filter((sub) => sub.plan.type === "PROXY")
.map((sub) => sub.planId),
),
);
}
export function getPlanTypeTone(type: SubscriptionRecord["plan"]["type"]): StatusTone {
return getSubscriptionTypeTone(type);
}
export function getPlanTypeLabel(type: SubscriptionRecord["plan"]["type"]) {
return subscriptionTypeLabels[type];
}
export function getSubscriptionStatusTone(status: SubscriptionRecord["status"]): StatusTone {
return getDomainSubscriptionStatusTone(status);
}
export function getSubscriptionStatusLabel(status: SubscriptionRecord["status"]) {
return subscriptionStatusLabels[status];
}
export function getTrafficPoolRemainingGb(
sub: SubscriptionRecord,
poolMap: Map<string, PlanTrafficPoolState>,
) {
if (sub.plan.type !== "PROXY") return null;
const poolState = poolMap.get(sub.planId);
return poolState?.enabled ? poolState.remainingGb : null;
}

View File

@@ -0,0 +1,36 @@
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { getSiteBaseUrl } from "@/services/site-url";
import {
getPlanTrafficPoolState,
type PlanTrafficPoolState,
} from "@/services/plan-traffic-pool";
import { getActiveSubscriptions, getProxyPlanIds } from "./subscriptions-calculations";
import type { SubscriptionRecord } from "./subscriptions-types";
export async function getUserSubscriptions(userId: string): Promise<SubscriptionRecord[]> {
return prisma.userSubscription.findMany({
where: { userId },
include: {
plan: { include: { node: true, category: true } },
nodeClient: { include: { inbound: { include: { server: true } } } },
streamingSlot: { include: { service: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function getSubscriptionBaseUrl() {
const requestHeaders = await headers();
return getSiteBaseUrl({ headers: requestHeaders });
}
export async function getTrafficPoolMap(subscriptions: SubscriptionRecord[]) {
const activeSubs = getActiveSubscriptions(subscriptions);
const proxyPlanIds = getProxyPlanIds(activeSubs);
const poolEntries = await Promise.all(
proxyPlanIds.map(async (planId) => [planId, await getPlanTrafficPoolState(planId)] as const),
);
return new Map<string, PlanTrafficPoolState>(poolEntries);
}

View File

@@ -0,0 +1,9 @@
import type { Prisma } from "@prisma/client";
export type SubscriptionRecord = Prisma.UserSubscriptionGetPayload<{
include: {
plan: { include: { node: true; category: true } };
nodeClient: { include: { inbound: { include: { server: true } } } };
streamingSlot: { include: { service: true } };
};
}>;

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import {
SupportTicketPriorityBadge,
SupportTicketStatusBadge,
} from "@/components/support/ticket-badges";
import { UserSupportTicketActions } from "@/components/support/user-ticket-actions";
import { formatDate } from "@/lib/utils";
import { SupportTicketReplyForm } from "../_components/support-ticket-reply-form";
import { SupportTicketThread } from "../_components/support-ticket-thread";
import { getUserSupportTicketDetail } from "../support-data";
export const metadata: Metadata = {
title: "工单详情",
description: "查看工单会话并继续回复。",
};
export default async function SupportTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getServerSession(authOptions);
const { id } = await params;
const ticket = await getUserSupportTicketDetail({
ticketId: id,
userId: session!.user.id,
});
if (!ticket) {
notFound();
}
return (
<PageShell>
<PageHeader
eyebrow="工单详情"
title={ticket.subject}
description={`创建于 ${formatDate(ticket.createdAt)},最近更新 ${formatDate(ticket.updatedAt)}`}
actions={
<UserSupportTicketActions
ticketId={ticket.id}
status={ticket.status}
redirectAfterDelete="/support"
/>
}
/>
<div className="flex flex-wrap items-center gap-2">
<SupportTicketStatusBadge status={ticket.status} />
<SupportTicketPriorityBadge priority={ticket.priority} />
{ticket.category && (
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
{ticket.category}
</span>
)}
</div>
<SupportTicketThread replies={ticket.replies} />
{ticket.status !== "CLOSED" && <SupportTicketReplyForm ticketId={ticket.id} />}
</PageShell>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Plus, X } from "lucide-react";
import { createSupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
export function CreateSupportTicketForm() {
const [open, setOpen] = useState(false);
if (!open) {
return (
<Button onClick={() => setOpen(true)} size="lg">
<Plus className="size-4" />
</Button>
);
}
return (
<form
id="new-ticket"
action={createSupportTicket}
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6"
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"></h3>
<button
type="button"
onClick={() => setOpen(false)}
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subject"></Label>
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" required />
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue="NORMAL"
className="h-11 w-full px-3 text-sm outline-none"
>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" />
</div>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" required />
</div>
<div className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label>
<Input
id="attachments"
name="attachments"
type="file"
multiple
accept={ATTACHMENT_ACCEPT}
/>
</div>
<Button type="submit" size="lg"></Button>
</form>
);
}

View File

@@ -0,0 +1,52 @@
import { Paperclip, Send } from "lucide-react";
import { replySupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { SUPPORT_ATTACHMENT_ACCEPT } from "@/services/support";
interface SupportTicketReplyFormProps {
ticketId: string;
}
export function SupportTicketReplyForm({ ticketId }: SupportTicketReplyFormProps) {
async function submitReply(formData: FormData) {
"use server";
await replySupportTicket(ticketId, formData);
}
return (
<form action={submitReply} className="form-panel space-y-5">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Send className="size-4" />
</span>
<div>
<h3 className="font-heading text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={4} placeholder="继续补充问题或回复客服团队" required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4">
<Label htmlFor="reply-attachments" className="inline-flex items-center gap-2">
<Paperclip className="size-4" />
</Label>
<Input
id="reply-attachments"
name="attachments"
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
/>
<p className="field-note">
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div>
<Button type="submit" size="lg" className="w-full sm:w-auto"></Button>
</form>
);
}

View File

@@ -0,0 +1 @@
export { SupportTicketThread } from "@/components/support/ticket-thread";

View File

@@ -0,0 +1,80 @@
import type { SupportTicket } from "@prisma/client";
import Link from "next/link";
import { LifeBuoy } from "lucide-react";
import { DataTableShell } from "@/components/shared/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SupportTicketPriorityBadge,
SupportTicketStatusBadge,
} from "@/components/support/ticket-badges";
import { UserSupportTicketActions } from "@/components/support/user-ticket-actions";
import { buttonVariants } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
interface UserSupportTicketTableProps {
tickets: SupportTicket[];
}
export function UserSupportTicketTable({ tickets }: UserSupportTicketTableProps) {
return (
<DataTableShell
isEmpty={tickets.length === 0}
emptyTitle="还没有工单"
emptyDescription="遇到支付、节点、流媒体或账户问题时,提交工单后会在这里跟进处理进度。"
emptyIcon={<LifeBuoy className="size-5" />}
emptyAction={
<a href="#new-ticket" className={buttonVariants()}>
</a>
}
>
<DataTable aria-label="我的工单列表" className="min-w-[760px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{tickets.map((ticket) => (
<DataTableRow key={ticket.id}>
<DataTableCell>
<Link href={`/support/${ticket.id}`} className="font-medium hover:underline">
{ticket.subject}
</Link>
{ticket.category && (
<p className="mt-1 text-xs text-muted-foreground">{ticket.category}</p>
)}
</DataTableCell>
<DataTableCell>
<SupportTicketStatusBadge status={ticket.status} />
</DataTableCell>
<DataTableCell>
<SupportTicketPriorityBadge priority={ticket.priority} />
</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<UserSupportTicketActions ticketId={ticket.id} status={ticket.status} />
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { CreateSupportTicketForm } from "./_components/create-support-ticket-form";
import { UserSupportTicketTable } from "./_components/user-support-ticket-table";
import { getUserSupportTickets } from "./support-data";
export const metadata: Metadata = {
title: "工单售后",
description: "提交问题并跟踪工单处理进度。",
};
export default async function SupportPage() {
const session = await getServerSession(authOptions);
const tickets = await getUserSupportTickets(session!.user.id);
return (
<PageShell>
<PageHeader
eyebrow="工单售后"
title="需要帮助?"
/>
<CreateSupportTicketForm />
<UserSupportTicketTable tickets={tickets} />
</PageShell>
);
}

View File

@@ -0,0 +1,51 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const supportTicketReplyInclude = {
author: {
select: { email: true },
},
attachments: {
select: {
id: true,
fileName: true,
mimeType: true,
size: true,
},
},
} satisfies Prisma.SupportTicketReplyInclude;
const userSupportTicketDetailInclude = {
replies: {
include: supportTicketReplyInclude,
orderBy: { createdAt: "asc" },
},
} satisfies Prisma.SupportTicketInclude;
export type UserSupportTicketDetail = Prisma.SupportTicketGetPayload<{
include: typeof userSupportTicketDetailInclude;
}>;
export async function getUserSupportTickets(userId: string) {
return prisma.supportTicket.findMany({
where: { userId },
orderBy: { updatedAt: "desc" },
take: 50,
});
}
export async function getUserSupportTicketDetail({
ticketId,
userId,
}: {
ticketId: string;
userId: string;
}) {
return prisma.supportTicket.findFirst({
where: {
id: ticketId,
userId,
},
include: userSupportTicketDetailInclude,
});
}