mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 09:14:11 +05:30
Initial commit
This commit is contained in:
95
src/app/(user)/account/_components/account-invite-card.tsx
Normal file
95
src/app/(user)/account/_components/account-invite-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/app/(user)/account/_components/account-password-card.tsx
Normal file
64
src/app/(user)/account/_components/account-password-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(user)/account/_components/account-profile-card.tsx
Normal file
60
src/app/(user)/account/_components/account-profile-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(user)/account/_components/site-notice.tsx
Normal file
17
src/app/(user)/account/_components/site-notice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/(user)/account/account-data.ts
Normal file
45
src/app/(user)/account/account-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
91
src/app/(user)/account/account-panel.tsx
Normal file
91
src/app/(user)/account/account-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(user)/account/account-types.ts
Normal file
9
src/app/(user)/account/account-types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface AccountPanelUser {
|
||||
email: string;
|
||||
name: string | null;
|
||||
inviteCode: string | null;
|
||||
createdAt: string;
|
||||
invitedUsersCount: number;
|
||||
inviteRewardCount: number;
|
||||
inviteRewardAmount: number;
|
||||
}
|
||||
29
src/app/(user)/account/page.tsx
Normal file
29
src/app/(user)/account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
src/app/(user)/cart/cart-client.tsx
Normal file
238
src/app/(user)/cart/cart-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/(user)/cart/cart-data.ts
Normal file
103
src/app/(user)/cart/cart-data.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
45
src/app/(user)/cart/page.tsx
Normal file
45
src/app/(user)/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(user)/dashboard/_components/dashboard-actions.tsx
Normal file
22
src/app/(user)/dashboard/_components/dashboard-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
93
src/app/(user)/dashboard/_components/first-run-guide.tsx
Normal file
93
src/app/(user)/dashboard/_components/first-run-guide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
36
src/app/(user)/dashboard/_components/traffic-section.tsx
Normal file
36
src/app/(user)/dashboard/_components/traffic-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 : "流媒体套餐";
|
||||
}
|
||||
52
src/app/(user)/dashboard/dashboard-calculations.ts
Normal file
52
src/app/(user)/dashboard/dashboard-calculations.ts
Normal 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)),
|
||||
),
|
||||
}));
|
||||
}
|
||||
63
src/app/(user)/dashboard/dashboard-data.ts
Normal file
63
src/app/(user)/dashboard/dashboard-data.ts
Normal 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)),
|
||||
}));
|
||||
}
|
||||
21
src/app/(user)/dashboard/dashboard-types.ts
Normal file
21
src/app/(user)/dashboard/dashboard-types.ts
Normal 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;
|
||||
}
|
||||
83
src/app/(user)/dashboard/page.tsx
Normal file
83
src/app/(user)/dashboard/page.tsx
Normal 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
26
src/app/(user)/error.tsx
Normal 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
52
src/app/(user)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/(user)/loading.tsx
Normal file
27
src/app/(user)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(user)/not-found.tsx
Normal file
22
src/app/(user)/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
104
src/app/(user)/notifications/notification-actions.tsx
Normal file
104
src/app/(user)/notifications/notification-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/(user)/notifications/notifications-calculations.ts
Normal file
24
src/app/(user)/notifications/notifications-calculations.ts
Normal 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 ? "已读" : "未读";
|
||||
}
|
||||
28
src/app/(user)/notifications/notifications-data.ts
Normal file
28
src/app/(user)/notifications/notifications-data.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
33
src/app/(user)/notifications/page.tsx
Normal file
33
src/app/(user)/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/app/(user)/orders/_components/user-orders-table.tsx
Normal file
87
src/app/(user)/orders/_components/user-orders-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/(user)/orders/order-actions.tsx
Normal file
32
src/app/(user)/orders/order-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(user)/orders/orders-calculations.ts
Normal file
17
src/app/(user)/orders/orders-calculations.ts
Normal 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 };
|
||||
35
src/app/(user)/orders/orders-data.ts
Normal file
35
src/app/(user)/orders/orders-data.ts
Normal 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 };
|
||||
}
|
||||
35
src/app/(user)/orders/page.tsx
Normal file
35
src/app/(user)/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/app/(user)/store/latency-detail-dialog.tsx
Normal file
145
src/app/(user)/store/latency-detail-dialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCarrierLabel } from "./proxy-signal-grid";
|
||||
|
||||
type Range = "1d" | "7d" | "30d";
|
||||
|
||||
interface HistoryData {
|
||||
carriers: string[];
|
||||
points: Record<string, string | number>[];
|
||||
sufficient: boolean;
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<Range, string> = { "1d": "1 天", "7d": "7 天", "30d": "30 天" };
|
||||
const CARRIER_COLORS: Record<string, string> = {
|
||||
telecom: "oklch(0.55 0.15 250)",
|
||||
unicom: "oklch(0.55 0.15 145)",
|
||||
mobile: "oklch(0.55 0.15 30)",
|
||||
};
|
||||
|
||||
export function LatencyDetailDialog({
|
||||
nodeId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
nodeId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [range, setRange] = useState<Range>("1d");
|
||||
const [data, setData] = useState<HistoryData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !nodeId) return;
|
||||
startTransition(() => {
|
||||
setLoading(true);
|
||||
});
|
||||
fetchJson<HistoryData>(`/api/latency/history?nodeId=${nodeId}&range=${range}`)
|
||||
.then(setData)
|
||||
.catch(() => setData(null))
|
||||
.finally(() => {
|
||||
startTransition(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
}, [open, nodeId, range]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (range === "1d") return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
if (range === "7d") return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="inline-flex items-center gap-2">
|
||||
<Activity className="size-4 text-primary" /> 延迟趋势
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-1.5 mb-4">
|
||||
{(Object.keys(RANGE_LABELS) as Range[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-semibold transition-colors",
|
||||
range === r
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/40 text-muted-foreground hover:bg-muted/70",
|
||||
)}
|
||||
>
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[420px]">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">加载中...</div>
|
||||
) : !data || data.points.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 text-sm text-muted-foreground">
|
||||
<Activity className="size-5" />
|
||||
<p>暂无延迟数据</p>
|
||||
</div>
|
||||
) : !data.sufficient && range !== "1d" ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-amber-500/30 bg-amber-500/5 text-sm text-muted-foreground">
|
||||
<Activity className="size-5 text-amber-500" />
|
||||
<p>数据不足 {RANGE_LABELS[range]},延迟记录仍在积累中</p>
|
||||
<p className="text-xs">请稍后再查看该时间范围</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.points}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tickFormatter={formatTime} tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis unit=" ms" width={55} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => formatTime(String(label))}
|
||||
formatter={(value, name) => [`${Number(value)} ms`, getCarrierLabel(String(name))]}
|
||||
/>
|
||||
<Legend formatter={getCarrierLabel} />
|
||||
{data.carriers.map((c) => (
|
||||
<Line
|
||||
key={c}
|
||||
type="monotone"
|
||||
dataKey={c}
|
||||
stroke={CARRIER_COLORS[c] ?? "oklch(0.5 0.1 0)"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
98
src/app/(user)/store/latency-loader.tsx
Normal file
98
src/app/(user)/store/latency-loader.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useEffectEvent, useSyncExternalStore } from "react";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
|
||||
export interface LatencyItem {
|
||||
carrier: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface LatencyRefreshMeta {
|
||||
loading: boolean;
|
||||
updatedAt: string | null;
|
||||
nextRefreshAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type LatencyMap = Record<string, LatencyItem[]>;
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
let latencyData: LatencyMap = {};
|
||||
let latencyMeta: LatencyRefreshMeta = {
|
||||
loading: false,
|
||||
updatedAt: null,
|
||||
nextRefreshAt: null,
|
||||
error: null,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function getSnapshot(): LatencyMap {
|
||||
return latencyData;
|
||||
}
|
||||
|
||||
function getMetaSnapshot(): LatencyRefreshMeta {
|
||||
return latencyMeta;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
function setLatencyData(data: LatencyMap) {
|
||||
latencyData = data;
|
||||
emit();
|
||||
}
|
||||
|
||||
function setLatencyMeta(meta: Partial<LatencyRefreshMeta>) {
|
||||
latencyMeta = { ...latencyMeta, ...meta };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function useLatency(nodeId: string | null): LatencyItem[] {
|
||||
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return nodeId ? data[nodeId] ?? [] : [];
|
||||
}
|
||||
|
||||
export function useLatencyRefreshMeta(): LatencyRefreshMeta {
|
||||
return useSyncExternalStore(subscribe, getMetaSnapshot, getMetaSnapshot);
|
||||
}
|
||||
|
||||
export function LatencyLoader({ nodeIds }: { nodeIds: string[] }) {
|
||||
const nodeIdKey = nodeIds.join(",");
|
||||
const load = useEffectEvent(async () => {
|
||||
if (!nodeIdKey) return;
|
||||
|
||||
setLatencyMeta({ loading: true, error: null });
|
||||
try {
|
||||
const result = await fetchJson<LatencyMap>(`/api/latency?nodeIds=${nodeIdKey}`);
|
||||
const now = Date.now();
|
||||
setLatencyData(result);
|
||||
setLatencyMeta({
|
||||
loading: false,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
nextRefreshAt: new Date(now + REFRESH_INTERVAL_MS).toISOString(),
|
||||
error: null,
|
||||
});
|
||||
} catch {
|
||||
setLatencyMeta({
|
||||
loading: false,
|
||||
error: "线路体验暂时无法刷新,稍后会自动重试。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), REFRESH_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [nodeIdKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
135
src/app/(user)/store/page.tsx
Normal file
135
src/app/(user)/store/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Film, LifeBuoy, Radio } from "lucide-react";
|
||||
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { EmptyState, PageShell } from "@/components/shared/page-shell";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { PendingOrderBanner } from "./pending-order-banner";
|
||||
import { ProxyPlanCard } from "./proxy-plan-card";
|
||||
import { StreamingPlanCard } from "./streaming-plan-card";
|
||||
import { StorePlanSection } from "./store-plan-section";
|
||||
import { StoreLatencyRecommendations } from "./store-latency-recommendations";
|
||||
|
||||
import { LatencyLoader } from "./latency-loader";
|
||||
import { TraceLoader } from "./trace-loader";
|
||||
import { getStorePageData } from "./store-data";
|
||||
import {
|
||||
getProxyNodeIds,
|
||||
getProxyPlans,
|
||||
getStreamingPlans,
|
||||
toProxyPlanCard,
|
||||
toStreamingPlanCard,
|
||||
} from "./store-plan-mappers";
|
||||
import { sortPlansForDisplay } from "./store-recommendations";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "套餐商店",
|
||||
description: "浏览代理与流媒体套餐,查看实时可用性并下单。",
|
||||
};
|
||||
|
||||
export default async function StorePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const { plans, availabilityMap, pendingOrder, latencyRecommendations } = await getStorePageData(session?.user.id);
|
||||
const proxyPlans = getProxyPlans(plans);
|
||||
const streamingPlans = getStreamingPlans(plans);
|
||||
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
|
||||
const streamingCards = sortPlansForDisplay(streamingPlans.map((plan) => toStreamingPlanCard(plan, availabilityMap.get(plan.id))));
|
||||
const proxyNodeIds = getProxyNodeIds(proxyPlans);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<section className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
订阅商店
|
||||
</p>
|
||||
<h1 className="text-display text-2xl font-semibold sm:text-3xl">
|
||||
选择你的连接方案
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="choice-card p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Radio className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">代理连接</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">按流量计费</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="choice-card p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<Film className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">流媒体共享</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">按周期订阅</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PendingOrderBanner order={pendingOrder} />
|
||||
|
||||
{proxyCards.length > 0 && (
|
||||
<StoreLatencyRecommendations initialItems={latencyRecommendations} />
|
||||
)}
|
||||
|
||||
{proxyCards.length > 0 && (
|
||||
<StorePlanSection
|
||||
id="proxy-plans"
|
||||
eyebrow="PROXY"
|
||||
title="代理连接"
|
||||
gridClassName="lg:grid-cols-2 xl:grid-cols-3"
|
||||
after={(
|
||||
<>
|
||||
{proxyNodeIds.length > 0 && (
|
||||
<>
|
||||
<LatencyLoader nodeIds={proxyNodeIds} />
|
||||
<TraceLoader nodeIds={proxyNodeIds} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{proxyCards.map((plan) => (
|
||||
<ProxyPlanCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</StorePlanSection>
|
||||
)}
|
||||
|
||||
{streamingCards.length > 0 && (
|
||||
<StorePlanSection
|
||||
id="streaming-plans"
|
||||
eyebrow="STREAMING"
|
||||
title="流媒体共享"
|
||||
>
|
||||
{streamingCards.map((plan) => (
|
||||
<StreamingPlanCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</StorePlanSection>
|
||||
)}
|
||||
|
||||
{plans.length === 0 && (
|
||||
<EmptyState
|
||||
eyebrow="商店准备中"
|
||||
icon={<LifeBuoy className="size-5" />}
|
||||
title="新的订阅正在准备"
|
||||
description="可购买的套餐会在这里出现。如果你希望提前了解补货时间,可以联系支持团队。"
|
||||
action={
|
||||
<Link href="/support" className={buttonVariants()}>
|
||||
联系支持
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
54
src/app/(user)/store/pending-order-banner.tsx
Normal file
54
src/app/(user)/store/pending-order-banner.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Clock3, ShoppingBag } from "lucide-react";
|
||||
import { cancelOwnPendingOrder } from "@/actions/user/orders";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
interface PendingStoreOrder {
|
||||
id: string;
|
||||
amount: number;
|
||||
planName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function PendingOrderBanner({ order }: { order: PendingStoreOrder | null }) {
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<section className="surface-card overflow-hidden rounded-xl p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<Clock3 className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">你有一笔订单正在等待支付</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{order.planName} · ¥{order.amount.toFixed(2)}。完成或取消后,才能开启新的购买。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Link href={`/pay/${order.id}`} className={buttonVariants({ size: "lg" })}>
|
||||
<ShoppingBag className="size-4" />
|
||||
继续支付
|
||||
</Link>
|
||||
<ConfirmActionButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
title="取消这笔订单?"
|
||||
description="取消后会释放本次占用的名额,你可以重新选择套餐或支付方式。"
|
||||
confirmLabel="取消订单"
|
||||
successMessage="订单已取消"
|
||||
errorMessage="取消订单失败"
|
||||
onConfirm={() => cancelOwnPendingOrder(order.id)}
|
||||
>
|
||||
取消后重选
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
49
src/app/(user)/store/plan-availability-badges.tsx
Normal file
49
src/app/(user)/store/plan-availability-badges.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
|
||||
interface PlanAvailabilityBadgesProps {
|
||||
totalLimit: number | null;
|
||||
perUserLimit: number | null;
|
||||
remainingCount: number | null;
|
||||
inboundCount?: number;
|
||||
hasInboundOptions?: boolean;
|
||||
isAvailable: boolean;
|
||||
unavailableLabel: string;
|
||||
missingInboundLabel?: string;
|
||||
}
|
||||
|
||||
export function PlanAvailabilityBadges({
|
||||
totalLimit,
|
||||
perUserLimit,
|
||||
remainingCount,
|
||||
inboundCount,
|
||||
hasInboundOptions,
|
||||
isAvailable,
|
||||
unavailableLabel,
|
||||
missingInboundLabel,
|
||||
}: PlanAvailabilityBadgesProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone="neutral" className="text-xs">
|
||||
{totalLimit == null ? "不限量" : `剩余 ${remainingCount ?? 0}`}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="neutral" className="text-xs">
|
||||
{perUserLimit == null ? "不限购" : `限购 ${perUserLimit}`}
|
||||
</StatusBadge>
|
||||
{inboundCount != null && inboundCount > 0 && (
|
||||
<StatusBadge tone="info" className="text-xs">
|
||||
{inboundCount} 个入站
|
||||
</StatusBadge>
|
||||
)}
|
||||
{hasInboundOptions === false && missingInboundLabel && (
|
||||
<StatusBadge tone="danger" className="text-xs">
|
||||
{missingInboundLabel}
|
||||
</StatusBadge>
|
||||
)}
|
||||
{!isAvailable && (
|
||||
<StatusBadge tone="danger" className="text-xs">
|
||||
{unavailableLabel}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/app/(user)/store/plan-card-parts.tsx
Normal file
44
src/app/(user)/store/plan-card-parts.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
interface StorePlanHeaderProps {
|
||||
name: string;
|
||||
meta?: string | null;
|
||||
price: string;
|
||||
priceSuffix: string;
|
||||
eyebrow?: ReactNode;
|
||||
}
|
||||
|
||||
export function StorePlanHeader({ name, meta, price, priceSuffix, eyebrow = "PROXY" }: StorePlanHeaderProps) {
|
||||
return (
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="inline-flex rounded-md border border-primary/15 bg-primary/10 px-2 py-0.5 text-xs font-medium tracking-wide text-primary">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em] text-balance">{name}</h3>
|
||||
{meta && <p className="mt-0.5 text-sm text-muted-foreground">{meta}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums">{price}</p>
|
||||
<p className="text-xs text-muted-foreground">{priceSuffix}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StorePlanDescription({ description }: { description: string | null }) {
|
||||
if (!description) return null;
|
||||
|
||||
return (
|
||||
<div className="px-5 pb-3">
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm leading-6 text-muted-foreground text-pretty [&_a]:text-primary [&_a]:underline [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-medium [&_li]:my-0 [&_ol]:my-1 [&_p]:my-1 [&_ul]:my-1">
|
||||
<ReactMarkdown>{description}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/app/(user)/store/proxy-detail-dialog.tsx
Normal file
219
src/app/(user)/store/proxy-detail-dialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Clock3, Network, Server, ShoppingCart } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseProxy } from "@/actions/user/purchase";
|
||||
import { addProxyPlanToCart } from "@/actions/user/cart";
|
||||
import { StorePlanDescription } from "./plan-card-parts";
|
||||
import { ProxySignalPanel } from "./proxy-signal-grid";
|
||||
import { useLatency } from "./latency-loader";
|
||||
import { useTraces, type TraceItem } from "./trace-loader";
|
||||
import {
|
||||
ProxyAvailabilityNotice,
|
||||
ProxyInboundSelect,
|
||||
ProxyPurchaseSummary,
|
||||
ProxyTrafficSlider,
|
||||
} from "./proxy-purchase-fields";
|
||||
import { usePlanAvailabilityCheck } from "./use-plan-availability-check";
|
||||
import { ProxyTraceDetailDialog } from "./proxy-trace-detail-dialog";
|
||||
import { LatencyDetailDialog } from "./latency-detail-dialog";
|
||||
import type { ProxyPlan } from "./proxy-plan-types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
plan: ProxyPlan;
|
||||
}
|
||||
|
||||
export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
|
||||
const fixedTrafficGb = plan.fixedTrafficGb ?? plan.minTrafficGb;
|
||||
const [trafficGb, setTrafficGb] = useState(
|
||||
plan.pricingMode === "FIXED_PACKAGE" ? fixedTrafficGb : plan.minTrafficGb,
|
||||
);
|
||||
const [selectedInboundId, setSelectedInboundId] = useState(plan.inboundOptions[0]?.id ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cartLoading, setCartLoading] = useState(false);
|
||||
const [selectedTrace, setSelectedTrace] = useState<TraceItem | null>(null);
|
||||
const [latencyDialogOpen, setLatencyDialogOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const hasInboundOptions = plan.inboundOptions.length > 0;
|
||||
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
|
||||
const { checking, checkAvailability } = usePlanAvailabilityCheck(plan.id);
|
||||
const latencyItems = useLatency(plan.nodeId);
|
||||
const traceItems = useTraces(plan.nodeId);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => (isFixedPackage ? (plan.fixedPrice ?? 0) : trafficGb * plan.pricePerGb).toFixed(2),
|
||||
[isFixedPackage, plan.fixedPrice, plan.pricePerGb, trafficGb],
|
||||
);
|
||||
|
||||
async function handlePurchase() {
|
||||
if (!selectedInboundId) {
|
||||
toast.error("请先选择一个线路入口");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const orderId = await purchaseProxy(plan.id, trafficGb, selectedInboundId);
|
||||
router.push(`/pay/${orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "下单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToCart() {
|
||||
if (!selectedInboundId) {
|
||||
toast.error("请先选择一个线路入口");
|
||||
return;
|
||||
}
|
||||
setCartLoading(true);
|
||||
try {
|
||||
await addProxyPlanToCart(plan.id, trafficGb, selectedInboundId);
|
||||
toast.success("已加入购物车");
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "加入购物车失败"));
|
||||
} finally {
|
||||
setCartLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-primary">
|
||||
<Network className="size-3.5" /> PROXY
|
||||
</div>
|
||||
<DialogTitle>{plan.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{plan.nodeName} · {plan.durationDays} 天 · ¥{displayPrice}{isFixedPackage ? "/套餐" : "/GB"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto -mx-6 px-6 space-y-4">
|
||||
{/* Compact info row — above the grid so both columns align */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5">
|
||||
<Server className="size-3.5 text-primary" />
|
||||
{plan.nodeName}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
{plan.durationDays} 天
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[1fr_20rem]">
|
||||
{/* Left: purchase config — always visible without scrolling */}
|
||||
<div className="space-y-3">
|
||||
<ProxyInboundSelect
|
||||
inbounds={plan.inboundOptions}
|
||||
value={selectedInboundId}
|
||||
onValueChange={setSelectedInboundId}
|
||||
disabled={!hasInboundOptions}
|
||||
/>
|
||||
|
||||
{isFixedPackage ? (
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2.5 text-sm">
|
||||
<span className="font-semibold text-primary">固定流量套餐</span>
|
||||
<span className="ml-2 text-muted-foreground">包含 {fixedTrafficGb} GB</span>
|
||||
</div>
|
||||
) : (
|
||||
<ProxyTrafficSlider
|
||||
value={trafficGb}
|
||||
min={plan.minTrafficGb}
|
||||
max={plan.maxTrafficGb}
|
||||
onChange={setTrafficGb}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProxyPurchaseSummary totalPrice={totalPrice} />
|
||||
|
||||
{!plan.isAvailable && (
|
||||
<ProxyAvailabilityNotice nextAvailableAt={plan.nextAvailableAt} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleAddToCart}
|
||||
disabled={cartLoading || !plan.isAvailable || !hasInboundOptions}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
{cartLoading ? "正在加入..." : "加入购物车"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
onClick={handlePurchase}
|
||||
disabled={loading || !plan.isAvailable || !hasInboundOptions}
|
||||
>
|
||||
{loading ? "正在保留..." : "立即支付"}
|
||||
{plan.isAvailable && <ArrowRight className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!plan.isAvailable && (
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={checkAvailability} disabled={checking}>
|
||||
{checking ? "查询中..." : "查看补位时间"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Description inline below actions */}
|
||||
{plan.description && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">套餐说明</p>
|
||||
<StorePlanDescription description={plan.description} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: signal data — supplementary, scrolls independently on desktop */}
|
||||
<div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3">
|
||||
<ProxySignalPanel
|
||||
latencyItems={latencyItems}
|
||||
traceItems={traceItems}
|
||||
onTraceSelect={setSelectedTrace}
|
||||
onLatencyClick={() => setLatencyDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProxyTraceDetailDialog
|
||||
trace={selectedTrace}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTrace(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LatencyDetailDialog
|
||||
nodeId={plan.nodeId}
|
||||
open={latencyDialogOpen}
|
||||
onOpenChange={setLatencyDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/app/(user)/store/proxy-plan-card.tsx
Normal file
86
src/app/(user)/store/proxy-plan-card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Gauge, Network, Search, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StorePlanHeader } from "./plan-card-parts";
|
||||
import { PlanAvailabilityBadges } from "./plan-availability-badges";
|
||||
import { ProxyDetailDialog } from "./proxy-detail-dialog";
|
||||
import type { ProxyPlan } from "./proxy-plan-types";
|
||||
|
||||
interface Props {
|
||||
plan: ProxyPlan;
|
||||
}
|
||||
|
||||
export function ProxyPlanCard({ plan }: Props) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const hasInboundOptions = plan.inboundOptions.length > 0;
|
||||
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
|
||||
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
id={`plan-${plan.id}`}
|
||||
className="surface-card surface-lift group relative scroll-mt-24 flex flex-col overflow-hidden rounded-xl text-left"
|
||||
>
|
||||
|
||||
<StorePlanHeader
|
||||
eyebrow={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Network className="size-3.5" /> PROXY
|
||||
</span>
|
||||
}
|
||||
name={plan.name}
|
||||
meta={`${plan.nodeName} · ${plan.durationDays} 天`}
|
||||
price={`¥${displayPrice}`}
|
||||
priceSuffix={isFixedPackage ? "/套餐" : "/GB"}
|
||||
/>
|
||||
|
||||
<div className="relative px-6">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Gauge className="size-3.5 text-primary" /> 可选流量
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{isFixedPackage ? `${plan.fixedTrafficGb ?? plan.minTrafficGb} GB 固定` : `${plan.minTrafficGb}-${plan.maxTrafficGb} GB`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Sparkles className="size-3.5 text-primary" /> 线路入口
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{hasInboundOptions ? `${plan.inboundOptions.length} 条可选` : "整理中"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto space-y-4 px-6 pb-6 pt-4">
|
||||
<PlanAvailabilityBadges
|
||||
totalLimit={plan.totalLimit}
|
||||
perUserLimit={plan.perUserLimit}
|
||||
remainingCount={plan.remainingCount}
|
||||
inboundCount={plan.inboundOptions.length}
|
||||
hasInboundOptions={hasInboundOptions}
|
||||
isAvailable={plan.isAvailable}
|
||||
missingInboundLabel="线路整理中"
|
||||
unavailableLabel="暂时售罄"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
查看详情与购买
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ProxyDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/app/(user)/store/proxy-plan-types.ts
Normal file
31
src/app/(user)/store/proxy-plan-types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface ProxyInboundOption {
|
||||
id: string;
|
||||
protocol: "VMESS" | "VLESS" | "TROJAN" | "SHADOWSOCKS" | "HYSTERIA2";
|
||||
port: number;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ProxyPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
durationDays: number;
|
||||
sortOrder: number;
|
||||
pricingMode: "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
|
||||
pricePerGb: number;
|
||||
fixedTrafficGb: number | null;
|
||||
fixedPrice: number | null;
|
||||
minTrafficGb: number;
|
||||
maxTrafficGb: number;
|
||||
nodeId: string | null;
|
||||
nodeName: string;
|
||||
inboundOptions: ProxyInboundOption[];
|
||||
totalLimit: number | null;
|
||||
perUserLimit: number | null;
|
||||
activeCount: number;
|
||||
remainingCount: number | null;
|
||||
remainingByUserLimit: number | null;
|
||||
isAvailable: boolean;
|
||||
nextAvailableAt: string | null;
|
||||
}
|
||||
147
src/app/(user)/store/proxy-purchase-fields.tsx
Normal file
147
src/app/(user)/store/proxy-purchase-fields.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Gauge, Router, WalletCards } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { ProxyInboundOption } from "./proxy-plan-types";
|
||||
|
||||
interface ProxyInboundSelectProps {
|
||||
inbounds: ProxyInboundOption[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ProxyInboundSelect({
|
||||
inbounds,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: ProxyInboundSelectProps) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Router className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">选择线路入口</p>
|
||||
<p className="text-xs text-muted-foreground">选择你想使用的连接入口,购买后可在订阅里复制或扫码导入。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue ?? "")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择一个线路入口">
|
||||
{(selectedValue) => {
|
||||
const match = inbounds.find((item) => item.id === selectedValue);
|
||||
return match ? formatInboundOption(match) : "选择一个线路入口";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inbounds.map((inbound) => (
|
||||
<SelectItem key={inbound.id} value={inbound.id}>
|
||||
{formatInboundOption(inbound)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{disabled && (
|
||||
<p className="flex items-center gap-2 text-xs font-medium text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
这条线路正在整理中,暂时不接受新的购买。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProxyTrafficSliderProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function ProxyTrafficSlider({ value, min, max, onChange }: ProxyTrafficSliderProps) {
|
||||
const percent = max > min ? Math.round(((value - min) / (max - min)) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Gauge className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">选择流量额度</p>
|
||||
<p className="text-xs text-muted-foreground">按你的本月使用量选择,开通后可随时在订阅页查看剩余额度。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2 text-right text-primary">
|
||||
<p className="text-2xl font-semibold tracking-[-0.05em] tabular-nums">{value}</p>
|
||||
<p className="text-[0.68rem] font-semibold tracking-[0.12em]">GB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[value]}
|
||||
onValueChange={(values: number | readonly number[]) => {
|
||||
const nextValue = Array.isArray(values) ? values[0] : values;
|
||||
onChange(nextValue ?? min);
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={max <= 100 ? 1 : 10}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{min} GB</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1">当前选择 {percent}%</span>
|
||||
<span>{max} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyPurchaseSummary({ totalPrice }: { totalPrice: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-[var(--shadow-button)]">
|
||||
<WalletCards className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">本次应付</p>
|
||||
<p className="text-xs text-muted-foreground">确认后为你保留订单名额</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyAvailabilityNotice({ nextAvailableAt }: { nextAvailableAt: string | null }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/15 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<p>
|
||||
这款套餐暂时售罄{nextAvailableAt ? `,预计 ${nextAvailableAt} 后有机会补位` : ""}。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatInboundOption(inbound: ProxyInboundOption) {
|
||||
return inbound.displayName || inbound.tag || "优选线路入口";
|
||||
}
|
||||
163
src/app/(user)/store/proxy-signal-grid.tsx
Normal file
163
src/app/(user)/store/proxy-signal-grid.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Clock3, RefreshCw, Route } from "lucide-react";
|
||||
import { useLatencyRefreshMeta, type LatencyItem } from "./latency-loader";
|
||||
import type { TraceItem } from "./trace-loader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
telecom: "电信",
|
||||
unicom: "联通",
|
||||
mobile: "移动",
|
||||
};
|
||||
|
||||
const CARRIER_ORDER: string[] = ["telecom", "unicom", "mobile"];
|
||||
|
||||
function sortByCarrier<T extends { carrier: string }>(items: T[]): T[] {
|
||||
return [...items].sort(
|
||||
(a, b) => (CARRIER_ORDER.indexOf(a.carrier) >>> 0) - (CARRIER_ORDER.indexOf(b.carrier) >>> 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCarrierLabel(carrier: string) {
|
||||
return carrierLabels[carrier] ?? carrier.replace("中国", "");
|
||||
}
|
||||
|
||||
function formatRefreshLabel(meta: ReturnType<typeof useLatencyRefreshMeta>) {
|
||||
if (meta.loading) return "正在刷新";
|
||||
if (!meta.updatedAt) return "约 1 分钟更新";
|
||||
|
||||
const updatedAt = new Date(meta.updatedAt);
|
||||
const nextRefreshAt = meta.nextRefreshAt ? new Date(meta.nextRefreshAt) : null;
|
||||
const updated = updatedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
const next = nextRefreshAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
return next ? `${updated} 更新 · ${next} 再刷` : `${updated} 已更新`;
|
||||
}
|
||||
|
||||
export function ProxySignalPanel({
|
||||
latencyItems,
|
||||
traceItems,
|
||||
onTraceSelect,
|
||||
onLatencyClick,
|
||||
}: {
|
||||
latencyItems: LatencyItem[];
|
||||
traceItems: TraceItem[];
|
||||
onTraceSelect: (item: TraceItem) => void;
|
||||
onLatencyClick?: () => void;
|
||||
}) {
|
||||
const refreshMeta = useLatencyRefreshMeta();
|
||||
|
||||
if (latencyItems.length === 0 && traceItems.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
|
||||
线路数据采集中,可先购买,开通后在订阅页查看连接状态。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<Activity className="size-4 text-primary" /> 线路体验
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">延迟与访问路径会持续更新,帮助你选择更舒服的线路。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold text-primary">
|
||||
{refreshMeta.loading ? <RefreshCw className="size-3 animate-spin" /> : <Clock3 className="size-3" />}
|
||||
{formatRefreshLabel(refreshMeta)}
|
||||
</div>
|
||||
{refreshMeta.error && (
|
||||
<div className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold text-amber-700 dark:text-amber-300">
|
||||
自动重试中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latencyItems.length > 0 && <ProxyLatencyGrid items={latencyItems} onClick={onLatencyClick} />}
|
||||
{traceItems.length > 0 && <ProxyTraceGrid items={traceItems} onTraceSelect={onTraceSelect} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyLatencyGrid({ items, onClick }: { items: LatencyItem[]; onClick?: () => void }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const sorted = sortByCarrier(items);
|
||||
const bestLatency = Math.min(...sorted.map((item) => item.latencyMs));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
<Activity className="size-3.5" /> 延迟{onClick && <span className="font-normal">· 点击查看趋势</span>}
|
||||
</p>
|
||||
<div className={cn("grid grid-cols-3 gap-2", onClick && "cursor-pointer")} onClick={onClick}>
|
||||
{sorted.map((item) => {
|
||||
const strong = item.latencyMs === bestLatency;
|
||||
return (
|
||||
<div
|
||||
key={item.carrier}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-3 text-center transition-colors duration-200",
|
||||
strong ? "border-primary/20 bg-primary/10 text-primary" : "border-border bg-background",
|
||||
onClick && "hover:border-primary/25 hover:bg-primary/7",
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-semibold leading-tight text-muted-foreground">
|
||||
{getCarrierLabel(item.carrier)}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight tabular-nums">
|
||||
{item.latencyMs}
|
||||
<span className="ml-0.5 text-xs font-normal text-muted-foreground">ms</span>
|
||||
</p>
|
||||
{strong && <p className="mt-1 text-[10px] font-semibold tracking-[0.14em]">BEST</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyTraceGrid({
|
||||
items,
|
||||
onTraceSelect,
|
||||
}: {
|
||||
items: TraceItem[];
|
||||
onTraceSelect: (item: TraceItem) => void;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const sorted = sortByCarrier(items);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
<Route className="size-3.5" /> 访问路径
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{sorted.map((item) => (
|
||||
<button
|
||||
key={item.carrier}
|
||||
type="button"
|
||||
className="group rounded-lg border border-border bg-background px-3 py-3 text-left transition-colors duration-200 hover:border-primary/25 hover:bg-primary/7 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTraceSelect(item);
|
||||
}}
|
||||
>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground">
|
||||
{getCarrierLabel(item.carrier)} · {item.hopCount} 跳
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs font-semibold tracking-tight group-hover:text-primary">
|
||||
{item.summary}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/app/(user)/store/proxy-trace-detail-dialog.tsx
Normal file
56
src/app/(user)/store/proxy-trace-detail-dialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getCarrierLabel } from "./proxy-signal-grid";
|
||||
import type { TraceItem } from "./trace-loader";
|
||||
|
||||
interface ProxyTraceDetailDialogProps {
|
||||
trace: TraceItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProxyTraceDetailDialog({ trace, onOpenChange }: ProxyTraceDetailDialogProps) {
|
||||
return (
|
||||
<Dialog open={trace !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{trace ? `${getCarrierLabel(trace.carrier)} 路由 — ${trace.summary}` : "路由详情"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{trace && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
共 {trace.hopCount} 跳
|
||||
{trace.updatedAt && ` · 更新于 ${new Date(trace.updatedAt).toLocaleString("zh-CN")}`}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{trace.hops.map((hop) => (
|
||||
<div
|
||||
key={`${hop.hop}-${hop.ip}`}
|
||||
className="flex items-baseline gap-2 text-xs py-1 px-2 rounded hover:bg-muted/40"
|
||||
>
|
||||
<span className="w-6 text-right text-muted-foreground shrink-0">
|
||||
{hop.hop}
|
||||
</span>
|
||||
<span className="font-mono min-w-0 truncate">{hop.ip || "*"}</span>
|
||||
<span className="text-muted-foreground truncate">{hop.geo}</span>
|
||||
{hop.latency > 0 && (
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">
|
||||
{hop.latency.toFixed(1)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
52
src/app/(user)/store/store-data.ts
Normal file
52
src/app/(user)/store/store-data.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeTraceText } from "@/lib/trace-normalize";
|
||||
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
|
||||
import { getLatencyRecommendations } from "@/services/latency-recommendations";
|
||||
|
||||
export async function getStorePageData(userId?: string) {
|
||||
const [plans, pendingOrder, latencyRecommendations] = await Promise.all([
|
||||
prisma.subscriptionPlan.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
node: true,
|
||||
inbound: true,
|
||||
streamingService: true,
|
||||
inboundOptions: {
|
||||
include: { inbound: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
}),
|
||||
userId
|
||||
? prisma.order.findFirst({
|
||||
where: { userId, status: "PENDING" },
|
||||
include: { plan: { select: { name: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: null,
|
||||
getLatencyRecommendations(),
|
||||
]);
|
||||
|
||||
const availabilityMap = new Map<string, PlanAvailability>();
|
||||
await Promise.all(
|
||||
plans.map(async (plan) => {
|
||||
const availability = await getPlanAvailability(plan, { userId });
|
||||
availabilityMap.set(plan.id, availability);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
plans,
|
||||
availabilityMap,
|
||||
latencyRecommendations,
|
||||
pendingOrder: pendingOrder
|
||||
? {
|
||||
id: pendingOrder.id,
|
||||
amount: Number(pendingOrder.amount),
|
||||
planName: normalizeTraceText(pendingOrder.plan.name),
|
||||
createdAt: pendingOrder.createdAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
139
src/app/(user)/store/store-latency-recommendations.tsx
Normal file
139
src/app/(user)/store/store-latency-recommendations.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, Clock3, RadioTower, RefreshCw, Sparkles } from "lucide-react";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RECOMMENDATION_CARRIERS,
|
||||
carrierLabels,
|
||||
type LatencyRecommendation,
|
||||
} from "@/services/latency-recommendation-types";
|
||||
|
||||
interface RecommendationPayload {
|
||||
items: LatencyRecommendation[];
|
||||
updatedAt: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
if (!value) return "等待刷新";
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getLatencyTone(latencyMs?: number) {
|
||||
if (latencyMs == null) return "border-dashed bg-muted/20 text-muted-foreground";
|
||||
if (latencyMs <= 80) return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
if (latencyMs <= 150) return "border-primary/20 bg-primary/10 text-primary";
|
||||
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
}
|
||||
|
||||
export function StoreLatencyRecommendations({
|
||||
initialItems,
|
||||
}: {
|
||||
initialItems: LatencyRecommendation[];
|
||||
}) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(initialItems[0]?.checkedAt ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await fetchJson<RecommendationPayload>("/api/latency/recommendations");
|
||||
if (cancelled) return;
|
||||
setItems(payload.items);
|
||||
setUpdatedAt(payload.updatedAt);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!cancelled) setError("推荐线路暂时无法刷新");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => void load(), REFRESH_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const itemMap = new Map(items.map((item) => [item.carrier, item]));
|
||||
|
||||
return (
|
||||
<section className="surface-card overflow-hidden rounded-2xl p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
<Sparkles className="size-3.5" /> 三网推荐
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold tracking-[-0.04em] sm:text-2xl">按最低延迟优先选节点</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||
自动比较电信、联通、移动三条线路当前最低延迟,推荐会每 5 分钟刷新一次。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-3 py-1">
|
||||
{loading ? <RefreshCw className="size-3.5 animate-spin" /> : <Clock3 className="size-3.5" />}
|
||||
{formatTime(updatedAt)} 更新
|
||||
</span>
|
||||
{error && <span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-3 py-1 text-amber-700 dark:text-amber-300">自动重试中</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||
{RECOMMENDATION_CARRIERS.map((carrier) => {
|
||||
const item = itemMap.get(carrier);
|
||||
return (
|
||||
<div
|
||||
key={carrier}
|
||||
className={cn(
|
||||
"rounded-xl border p-4 transition-colors duration-200",
|
||||
getLatencyTone(item?.latencyMs),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<RadioTower className="size-4" /> {carrierLabels[carrier]}
|
||||
</p>
|
||||
{item && (
|
||||
<span className="rounded-full bg-background/70 px-2.5 py-1 text-xs font-semibold tabular-nums">
|
||||
{item.latencyMs} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-lg font-semibold tracking-[-0.04em] text-foreground">{item.nodeName}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{item.planName}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`#plan-${item.planId}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
<Activity className="size-3.5" /> 查看套餐
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm leading-6 text-muted-foreground">正在采集这个运营商的延迟数据。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
137
src/app/(user)/store/store-plan-mappers.ts
Normal file
137
src/app/(user)/store/store-plan-mappers.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
formatAvailabilityDateTime,
|
||||
type PlanAvailability,
|
||||
} from "@/services/plan-availability";
|
||||
import { normalizeTraceText } from "@/lib/trace-normalize";
|
||||
import type { ProxyPlan } from "./proxy-plan-types";
|
||||
import type { StreamingPlan } from "./streaming-plan-types";
|
||||
|
||||
export type StorePlanRecord = Prisma.SubscriptionPlanGetPayload<{
|
||||
include: {
|
||||
node: true;
|
||||
inbound: true;
|
||||
streamingService: true;
|
||||
inboundOptions: {
|
||||
include: { inbound: true };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export function getProxyPlans(plans: StorePlanRecord[]) {
|
||||
return plans.filter((plan) => plan.type === "PROXY");
|
||||
}
|
||||
|
||||
export function getStreamingPlans(plans: StorePlanRecord[]) {
|
||||
return plans.filter((plan) => plan.type === "STREAMING");
|
||||
}
|
||||
|
||||
export function getProxyNodeIds(plans: StorePlanRecord[]) {
|
||||
const ids = new Set<string>();
|
||||
for (const plan of plans) {
|
||||
if (plan.nodeId) ids.add(plan.nodeId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function toProxyPlanCard(plan: StorePlanRecord, availability?: PlanAvailability): ProxyPlan {
|
||||
const inboundOptions = getAvailableInboundOptions(plan);
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
name: normalizeTraceText(plan.name),
|
||||
description: plan.description ? normalizeTraceText(plan.description) : null,
|
||||
sortOrder: plan.sortOrder,
|
||||
durationDays: plan.durationDays,
|
||||
pricingMode: plan.pricingMode,
|
||||
pricePerGb: Number(plan.pricePerGb ?? 0),
|
||||
fixedTrafficGb: plan.fixedTrafficGb,
|
||||
fixedPrice: plan.fixedPrice == null ? null : Number(plan.fixedPrice),
|
||||
minTrafficGb: plan.pricingMode === "FIXED_PACKAGE" ? (plan.fixedTrafficGb ?? plan.minTrafficGb ?? 10) : (plan.minTrafficGb ?? 10),
|
||||
maxTrafficGb: plan.pricingMode === "FIXED_PACKAGE" ? (plan.fixedTrafficGb ?? plan.maxTrafficGb ?? 1000) : (plan.maxTrafficGb ?? 1000),
|
||||
nodeId: plan.nodeId,
|
||||
nodeName: plan.node?.name ? normalizeTraceText(plan.node.name) : "未知节点",
|
||||
inboundOptions: inboundOptions.map((inbound) => ({
|
||||
id: inbound.id,
|
||||
protocol: inbound.protocol,
|
||||
port: inbound.port,
|
||||
tag: normalizeTraceText(inbound.tag),
|
||||
displayName: getInboundDisplayName(inbound),
|
||||
})),
|
||||
totalLimit: plan.totalLimit,
|
||||
perUserLimit: plan.perUserLimit,
|
||||
activeCount: availability?.activeCount ?? 0,
|
||||
remainingCount: availability?.remainingByPlanLimit ?? null,
|
||||
remainingByUserLimit: availability?.remainingByUserLimit ?? null,
|
||||
isAvailable: availability?.available ?? true,
|
||||
nextAvailableAt: formatNextAvailableAt(availability),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStreamingPlanCard(
|
||||
plan: StorePlanRecord,
|
||||
availability?: PlanAvailability,
|
||||
): StreamingPlan {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: normalizeTraceText(plan.name),
|
||||
description: plan.description ? normalizeTraceText(plan.description) : null,
|
||||
sortOrder: plan.sortOrder,
|
||||
durationDays: plan.durationDays,
|
||||
price: Number(plan.price),
|
||||
serviceName: plan.streamingService?.name ? normalizeTraceText(plan.streamingService.name) : null,
|
||||
totalLimit: plan.totalLimit,
|
||||
perUserLimit: plan.perUserLimit,
|
||||
activeCount: availability?.activeCount ?? 0,
|
||||
remainingCount: getStreamingRemainingCount(availability),
|
||||
remainingByUserLimit: availability?.remainingByUserLimit ?? null,
|
||||
isAvailable: availability?.available ?? true,
|
||||
nextAvailableAt: formatNextAvailableAt(availability),
|
||||
};
|
||||
}
|
||||
|
||||
function getAvailableInboundOptions(plan: StorePlanRecord) {
|
||||
if (plan.inboundOptions.length > 0) {
|
||||
return plan.inboundOptions
|
||||
.map((option) => option.inbound)
|
||||
.filter((inbound) => inbound.isActive && inbound.serverId === plan.nodeId);
|
||||
}
|
||||
|
||||
if (plan.inbound && plan.inbound.isActive && plan.inbound.serverId === plan.nodeId) {
|
||||
return [plan.inbound];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getStreamingRemainingCount(availability?: PlanAvailability) {
|
||||
if (!availability) return null;
|
||||
|
||||
if (
|
||||
availability.remainingByPlanLimit == null &&
|
||||
availability.remainingByServiceCapacity != null
|
||||
) {
|
||||
return availability.remainingByServiceCapacity;
|
||||
}
|
||||
|
||||
return availability.remainingByPlanLimit ?? null;
|
||||
}
|
||||
|
||||
function formatNextAvailableAt(availability?: PlanAvailability) {
|
||||
return availability?.nextAvailableAt
|
||||
? formatAvailabilityDateTime(availability.nextAvailableAt)
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
function getInboundDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||
const settings = inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return normalizeTraceText(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeTraceText(inbound.tag) || "优选线路入口";
|
||||
}
|
||||
39
src/app/(user)/store/store-plan-section.tsx
Normal file
39
src/app/(user)/store/store-plan-section.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StorePlanSectionProps {
|
||||
id?: string;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
after?: ReactNode;
|
||||
gridClassName?: string;
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
export function StorePlanSection({
|
||||
id,
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
after,
|
||||
gridClassName,
|
||||
stacked = false,
|
||||
}: StorePlanSectionProps) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-8 space-y-6">
|
||||
<div className="max-w-2xl">
|
||||
{eyebrow && (
|
||||
<p className="mb-1.5 inline-flex rounded-md border border-primary/15 bg-primary/10 px-2 py-0.5 text-xs font-medium tracking-wide text-primary">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="font-heading text-lg font-semibold tracking-[-0.02em] sm:text-xl">{title}</h2>
|
||||
</div>
|
||||
<div className={cn(stacked ? "space-y-5" : "grid gap-6 md:grid-cols-2 xl:grid-cols-3", !stacked && gridClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{after}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
11
src/app/(user)/store/store-recommendations.ts
Normal file
11
src/app/(user)/store/store-recommendations.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
interface DisplayPlanBase {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function sortPlansForDisplay<T extends DisplayPlanBase>(plans: T[]): T[] {
|
||||
return [...plans].sort((a, b) => {
|
||||
return a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN");
|
||||
});
|
||||
}
|
||||
137
src/app/(user)/store/streaming-detail-dialog.tsx
Normal file
137
src/app/(user)/store/streaming-detail-dialog.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Film, ShoppingCart } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseStreaming } from "@/actions/user/purchase";
|
||||
import { addStreamingPlanToCart } from "@/actions/user/cart";
|
||||
import { StorePlanDescription } from "./plan-card-parts";
|
||||
import { PlanAvailabilityBadges } from "./plan-availability-badges";
|
||||
import { usePlanAvailabilityCheck } from "./use-plan-availability-check";
|
||||
import type { StreamingPlan } from "./streaming-plan-types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
plan: StreamingPlan;
|
||||
}
|
||||
|
||||
export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cartLoading, setCartLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { checking, checkAvailability } = usePlanAvailabilityCheck(plan.id);
|
||||
|
||||
async function handlePurchase() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const orderId = await purchaseStreaming(plan.id);
|
||||
router.push(`/pay/${orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "下单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToCart() {
|
||||
setCartLoading(true);
|
||||
try {
|
||||
await addStreamingPlanToCart(plan.id);
|
||||
toast.success("已加入购物车");
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "加入购物车失败"));
|
||||
} finally {
|
||||
setCartLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/15 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-amber-700 dark:text-amber-300">
|
||||
<Film className="size-3.5" /> STREAMING
|
||||
</div>
|
||||
<DialogTitle>{plan.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{plan.serviceName ?? plan.name} · {plan.durationDays}天 · ¥{plan.price.toFixed(0)}/{plan.durationDays}天
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto -mx-6 px-6 space-y-5">
|
||||
{plan.description && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
服务说明
|
||||
</p>
|
||||
<StorePlanDescription description={plan.description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
库存状态
|
||||
</p>
|
||||
<PlanAvailabilityBadges
|
||||
totalLimit={plan.totalLimit}
|
||||
perUserLimit={plan.perUserLimit}
|
||||
remainingCount={plan.remainingCount}
|
||||
isAvailable={plan.isAvailable}
|
||||
unavailableLabel="暂时售罄"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!plan.isAvailable && (
|
||||
<p className="rounded-[1rem] border border-destructive/20 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive">
|
||||
当前名额已满{plan.nextAvailableAt ? `,预计 ${plan.nextAvailableAt} 后可能补位` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={handleAddToCart}
|
||||
disabled={cartLoading || !plan.isAvailable}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
{cartLoading ? "正在加入..." : "加入购物车"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handlePurchase}
|
||||
disabled={loading || !plan.isAvailable}
|
||||
>
|
||||
{loading ? "正在保留..." : "立即支付"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!plan.isAvailable && (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={checkAvailability}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? "查询中..." : "查看补位时间"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
78
src/app/(user)/store/streaming-plan-card.tsx
Normal file
78
src/app/(user)/store/streaming-plan-card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Film, Search, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StorePlanHeader } from "./plan-card-parts";
|
||||
import { PlanAvailabilityBadges } from "./plan-availability-badges";
|
||||
import { StreamingDetailDialog } from "./streaming-detail-dialog";
|
||||
import type { StreamingPlan } from "./streaming-plan-types";
|
||||
|
||||
interface Props {
|
||||
plan: StreamingPlan;
|
||||
}
|
||||
|
||||
export function StreamingPlanCard({ plan }: Props) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
id={`plan-${plan.id}`}
|
||||
className="surface-card surface-lift group relative scroll-mt-24 flex flex-col overflow-hidden rounded-xl text-left"
|
||||
>
|
||||
|
||||
<StorePlanHeader
|
||||
eyebrow={
|
||||
<span className="inline-flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<Film className="size-3.5" /> STREAMING
|
||||
</span>
|
||||
}
|
||||
name={plan.name}
|
||||
meta={plan.serviceName ? `${plan.serviceName} · ${plan.durationDays} 天` : `${plan.durationDays} 天`}
|
||||
price={`¥${plan.price.toFixed(0)}`}
|
||||
priceSuffix={`/${plan.durationDays}天`}
|
||||
/>
|
||||
|
||||
<div className="relative px-6">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Film className="size-3.5 text-amber-600" /> 服务类型
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{plan.serviceName ?? "精选流媒体"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Sparkles className="size-3.5 text-amber-600" /> 交付方式
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">支付后在订阅页查看</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto space-y-4 px-6 pb-6 pt-4">
|
||||
<PlanAvailabilityBadges
|
||||
totalLimit={plan.totalLimit}
|
||||
perUserLimit={plan.perUserLimit}
|
||||
remainingCount={plan.remainingCount}
|
||||
isAvailable={plan.isAvailable}
|
||||
unavailableLabel="暂时售罄"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
查看详情与购买
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<StreamingDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/(user)/store/streaming-plan-types.ts
Normal file
16
src/app/(user)/store/streaming-plan-types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface StreamingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
durationDays: number;
|
||||
price: number;
|
||||
serviceName: string | null;
|
||||
totalLimit: number | null;
|
||||
perUserLimit: number | null;
|
||||
activeCount: number;
|
||||
remainingCount: number | null;
|
||||
remainingByUserLimit: number | null;
|
||||
isAvailable: boolean;
|
||||
nextAvailableAt: string | null;
|
||||
}
|
||||
64
src/app/(user)/store/trace-loader.tsx
Normal file
64
src/app/(user)/store/trace-loader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useEffectEvent, useSyncExternalStore } from "react";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
|
||||
export interface HopDetail {
|
||||
hop: number;
|
||||
ip: string;
|
||||
geo: string;
|
||||
latency: number;
|
||||
}
|
||||
|
||||
export interface TraceItem {
|
||||
carrier: string;
|
||||
summary: string;
|
||||
hopCount: number;
|
||||
hops: HopDetail[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type TraceMap = Record<string, TraceItem[]>;
|
||||
|
||||
let traceData: TraceMap = {};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function getSnapshot(): TraceMap {
|
||||
return traceData;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
function setTraceData(data: TraceMap) {
|
||||
traceData = data;
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
export function useTraces(nodeId: string | null): TraceItem[] {
|
||||
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return nodeId ? data[nodeId] ?? [] : [];
|
||||
}
|
||||
|
||||
export function TraceLoader({ nodeIds }: { nodeIds: string[] }) {
|
||||
const nodeIdKey = nodeIds.join(",");
|
||||
const load = useEffectEvent(async () => {
|
||||
if (!nodeIdKey) return;
|
||||
try {
|
||||
const result = await fetchJson<TraceMap>(
|
||||
`/api/traces?nodeIds=${nodeIdKey}`,
|
||||
);
|
||||
setTraceData(result);
|
||||
} catch {
|
||||
// Trace data is non-critical — silently ignore
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [nodeIdKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
28
src/app/(user)/store/use-plan-availability-check.ts
Normal file
28
src/app/(user)/store/use-plan-availability-check.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { queryPlanNextAvailability } from "@/actions/user/purchase";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
export function usePlanAvailabilityCheck(planId: string) {
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function checkAvailability() {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await queryPlanNextAvailability(planId);
|
||||
if (result.available) {
|
||||
toast.success("这款套餐现在可以购买");
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "暂时无法确认补位时间"));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { checking, checkAvailability };
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
71
src/app/(user)/subscriptions/[id]/page.tsx
Normal file
71
src/app/(user)/subscriptions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { ArrowUpRight, CalendarClock, Database, Radio, Server, Tv } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { SubscriptionActions } from "../subscription-actions";
|
||||
import {
|
||||
getPlanTypeLabel,
|
||||
getPlanTypeTone,
|
||||
getTrafficPoolRemainingGb,
|
||||
} from "../subscriptions-calculations";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
|
||||
|
||||
interface ActiveSubscriptionCardProps {
|
||||
sub: SubscriptionRecord;
|
||||
poolMap: Map<string, PlanTrafficPoolState>;
|
||||
}
|
||||
|
||||
function getInboundDisplayName(sub: SubscriptionRecord) {
|
||||
const settings = sub.nodeClient?.inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return sub.nodeClient?.inbound.tag ?? "等待分配";
|
||||
}
|
||||
|
||||
function ProxyCompactSummary({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "PROXY") return null;
|
||||
|
||||
const used = Number(sub.trafficUsed);
|
||||
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
|
||||
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
const nodeName = sub.nodeClient?.inbound.server.name ?? sub.plan.node?.name ?? "待分配";
|
||||
const inboundName = getInboundDisplayName(sub);
|
||||
const protocol = sub.nodeClient?.inbound.protocol ?? "—";
|
||||
const port = sub.nodeClient?.inbound.port ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-3">
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Server className="size-3.5 text-primary" /> 节点
|
||||
</p>
|
||||
<p className="mt-1 truncate font-semibold">{nodeName}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Radio className="size-3.5 text-primary" /> 入站
|
||||
</p>
|
||||
<p className="mt-1 truncate font-semibold">{protocol}{port ? ` · ${port}` : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-background/70 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
<Database className="size-3.5 text-primary" />
|
||||
<span className="truncate">{inboundName}</span>
|
||||
</span>
|
||||
{limit && <span className="font-semibold text-primary tabular-nums">{percent}%</span>}
|
||||
</div>
|
||||
{limit ? (
|
||||
<>
|
||||
<Progress value={percent} />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatBytes(used)} / {formatBytes(limit)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">不限流量 · 已用 {formatBytes(used)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingCompactSummary({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "STREAMING") return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/25 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Tv className="size-3.5 text-primary" /> 流媒体服务
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">{sub.streamingSlot?.service.name ?? "账号分配中"}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">账号凭据和使用说明请进入详情页查看。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveSubscriptionCard({ sub, poolMap }: ActiveSubscriptionCardProps) {
|
||||
return (
|
||||
<Card className="group overflow-hidden transition-colors duration-200 hover:border-primary/25 hover:bg-muted/10">
|
||||
<CardHeader className="gap-3 p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{sub.plan.type === "PROXY" ? <Radio className="size-4" /> : <Tv className="size-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<CardTitle className="text-base">
|
||||
<Link
|
||||
href={`/subscriptions/${sub.id}`}
|
||||
className="group/link inline-flex max-w-full items-center gap-1.5 hover:text-primary"
|
||||
>
|
||||
<span className="truncate">{sub.plan.name}</span>
|
||||
<ArrowUpRight className="size-3.5 opacity-45 transition-transform duration-300 group-hover/link:-translate-y-0.5 group-hover/link:translate-x-0.5 group-hover/link:opacity-100" />
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="inline-flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })} 到期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge tone={getPlanTypeTone(sub.plan.type)} className="h-6 px-2 text-[11px]">
|
||||
{getPlanTypeLabel(sub.plan.type)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-1">
|
||||
<ProxyCompactSummary sub={sub} />
|
||||
<StreamingCompactSummary sub={sub} />
|
||||
<SubscriptionActions
|
||||
subscriptionId={sub.id}
|
||||
type={sub.plan.type}
|
||||
allowRenewal={sub.plan.allowRenewal}
|
||||
allowTrafficTopup={sub.plan.type === "PROXY" && sub.plan.allowTrafficTopup}
|
||||
trafficPoolRemainingGb={getTrafficPoolRemainingGb(sub, poolMap)}
|
||||
renewalConfig={{
|
||||
durationDays: sub.plan.durationDays,
|
||||
renewalPrice: sub.plan.renewalPrice == null ? null : Number(sub.plan.renewalPrice),
|
||||
renewalPricingMode: sub.plan.renewalPricingMode,
|
||||
renewalDurationDays: sub.plan.renewalDurationDays,
|
||||
renewalMinDays: sub.plan.renewalMinDays,
|
||||
renewalMaxDays: sub.plan.renewalMaxDays,
|
||||
}}
|
||||
topupConfig={{
|
||||
topupPricingMode: sub.plan.topupPricingMode,
|
||||
topupPricePerGb: sub.plan.topupPricePerGb == null ? null : Number(sub.plan.topupPricePerGb),
|
||||
topupFixedPrice: sub.plan.topupFixedPrice == null ? null : Number(sub.plan.topupFixedPrice),
|
||||
minTopupGb: sub.plan.minTopupGb,
|
||||
maxTopupGb: sub.plan.maxTopupGb,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { Film, Radio, ShoppingBag } from "lucide-react";
|
||||
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { CollapsibleGroup } from "@/components/shared/collapsible-group";
|
||||
import type { PlanTrafficPoolState } from "@/services/plan-traffic-pool";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import { ActiveSubscriptionCard } from "./active-subscription-card";
|
||||
import { AggregateSubscriptionCard } from "./aggregate-subscription-card";
|
||||
|
||||
interface ActiveSubscriptionsSectionProps {
|
||||
subscriptions: SubscriptionRecord[];
|
||||
aggregateSubscriptionUrl: string | null;
|
||||
poolMap: Map<string, PlanTrafficPoolState>;
|
||||
}
|
||||
|
||||
function toBigInt(value: bigint | number | null | undefined) {
|
||||
if (typeof value === "bigint") return value;
|
||||
return BigInt(value ?? 0);
|
||||
}
|
||||
|
||||
function groupSubscriptions(subscriptions: SubscriptionRecord[], type: "PROXY" | "STREAMING") {
|
||||
const groups = new Map<string, { title: string; subtitle: string; items: SubscriptionRecord[] }>();
|
||||
for (const sub of subscriptions.filter((item) => item.plan.type === type)) {
|
||||
const key = sub.plan.category?.id ?? `default-${type}`;
|
||||
const fallbackTitle = type === "PROXY" ? "代理连接" : "流媒体共享";
|
||||
const group = groups.get(key) ?? {
|
||||
title: sub.plan.category?.name ?? fallbackTitle,
|
||||
subtitle: "",
|
||||
items: [],
|
||||
};
|
||||
group.items.push(sub);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.values()).map((g) => ({
|
||||
...g,
|
||||
subtitle: `${g.items.length} 个订阅`,
|
||||
}));
|
||||
}
|
||||
|
||||
function getProxySummary(subscriptions: SubscriptionRecord[]) {
|
||||
const proxySubscriptions = subscriptions.filter((sub) => sub.plan.type === "PROXY" && sub.nodeClient);
|
||||
let totalUsed = BigInt(0);
|
||||
let totalLimit = BigInt(0);
|
||||
let hasUnlimited = false;
|
||||
let nextExpiry: Date | null = null;
|
||||
|
||||
for (const sub of proxySubscriptions) {
|
||||
totalUsed += toBigInt(sub.trafficUsed);
|
||||
if (sub.trafficLimit == null) {
|
||||
hasUnlimited = true;
|
||||
} else {
|
||||
totalLimit += toBigInt(sub.trafficLimit);
|
||||
}
|
||||
if (!nextExpiry || sub.endDate < nextExpiry) {
|
||||
nextExpiry = sub.endDate;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount: proxySubscriptions.length,
|
||||
totalUsed,
|
||||
totalLimit: hasUnlimited ? null : totalLimit,
|
||||
nextExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
export function ActiveSubscriptionsSection({
|
||||
subscriptions,
|
||||
aggregateSubscriptionUrl,
|
||||
poolMap,
|
||||
}: ActiveSubscriptionsSectionProps) {
|
||||
const proxyGroups = groupSubscriptions(subscriptions, "PROXY");
|
||||
const streamingGroups = groupSubscriptions(subscriptions, "STREAMING");
|
||||
const proxySummary = getProxySummary(subscriptions);
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SectionHeader title="活跃订阅" />
|
||||
{subscriptions.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="下一步"
|
||||
icon={<ShoppingBag className="size-5" />}
|
||||
title="还没有正在使用的订阅"
|
||||
description="选择套餐并完成支付后,这里会显示统一订阅链接、节点概览和续费入口。"
|
||||
action={
|
||||
<Link href="/store" className={buttonVariants()}>
|
||||
去商店选择套餐
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-7">
|
||||
{proxySummary.nodeCount > 0 && aggregateSubscriptionUrl && (
|
||||
<AggregateSubscriptionCard
|
||||
subscriptionUrl={aggregateSubscriptionUrl}
|
||||
nodeCount={proxySummary.nodeCount}
|
||||
totalUsed={proxySummary.totalUsed}
|
||||
totalLimit={proxySummary.totalLimit}
|
||||
nextExpiry={proxySummary.nextExpiry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{proxyGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="节点概览"
|
||||
description="节点卡片只保留状态、流量和操作;配置、二维码和日志放到详情页。"
|
||||
actions={<Radio className="size-5 text-primary" />}
|
||||
/>
|
||||
{proxyGroups.map((group, index) => (
|
||||
<CollapsibleGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
subtitle={group.subtitle}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((sub) => (
|
||||
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamingGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="流媒体服务"
|
||||
description="账号信息已收进详情页,列表只显示可用状态。"
|
||||
actions={<Film className="size-5 text-primary" />}
|
||||
/>
|
||||
{streamingGroups.map((group, index) => (
|
||||
<CollapsibleGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
subtitle={group.subtitle}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((sub) => (
|
||||
<ActiveSubscriptionCard key={sub.id} sub={sub} poolMap={poolMap} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react";
|
||||
import { CopyButton } from "@/components/shared/copy-button";
|
||||
import { QrPreview } from "@/components/shared/qr-preview";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
|
||||
interface AggregateSubscriptionCardProps {
|
||||
subscriptionUrl: string;
|
||||
nodeCount: number;
|
||||
totalUsed: bigint;
|
||||
totalLimit: bigint | null;
|
||||
nextExpiry: Date | null;
|
||||
}
|
||||
|
||||
export function AggregateSubscriptionCard({
|
||||
subscriptionUrl,
|
||||
nodeCount,
|
||||
totalUsed,
|
||||
totalLimit,
|
||||
nextExpiry,
|
||||
}: AggregateSubscriptionCardProps) {
|
||||
const percent = totalLimit
|
||||
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6">
|
||||
<div className="absolute right-8 top-8 size-32 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="relative grid gap-6 lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
<Sparkles className="size-3.5" /> 总订阅链接
|
||||
</div>
|
||||
<div className="max-w-2xl space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-balance sm:text-3xl">
|
||||
一个链接导入全部节点
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||
新购买的代理节点会自动加入这个链接;到期或停用的节点会自动从订阅内容中移除,不需要重复导入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
<Link2 className="size-3.5" /> SUBSCRIPTION URL
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-foreground/80">{subscriptionUrl}</p>
|
||||
</div>
|
||||
<CopyButton text={subscriptionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Layers3 className="size-3.5 text-primary" /> 已合并节点
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{nodeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Gauge className="size-3.5 text-primary" /> 总用量
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-[-0.04em] tabular-nums">{formatBytes(totalUsed)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CalendarClock className="size-3.5 text-primary" /> 最近到期
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
{nextExpiry ? format(nextExpiry, "MM-dd HH:mm", { locale: zhCN }) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalLimit && (
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>合计流量进度</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{percent}%</span>
|
||||
</div>
|
||||
<Progress value={percent} />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
总额度 {formatBytes(totalLimit)} · 剩余 {formatBytes(totalLimit - totalUsed > BigInt(0) ? totalLimit - totalUsed : BigInt(0))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<QrPreview label="总订阅二维码" value={subscriptionUrl} alt="总订阅链接二维码" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { Archive } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { EmptyState, SectionHeader } from "@/components/shared/page-shell";
|
||||
import {
|
||||
getSubscriptionStatusLabel,
|
||||
getSubscriptionStatusTone,
|
||||
} from "../subscriptions-calculations";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
|
||||
export function HistorySubscriptionsSection({
|
||||
subscriptions,
|
||||
}: {
|
||||
subscriptions: SubscriptionRecord[];
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<SectionHeader title="历史记录" />
|
||||
{subscriptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Archive className="size-5" />}
|
||||
title="历史记录还是空的"
|
||||
description="过期、暂停或取消后的订阅会在这里保留记录,方便你之后回看。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{subscriptions.map((sub) => (
|
||||
<Card key={sub.id} className="border-muted-foreground/15">
|
||||
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{sub.plan.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(sub.startDate, "yyyy-MM-dd", { locale: zhCN })} ~{" "}
|
||||
{format(sub.endDate, "yyyy-MM-dd", { locale: zhCN })}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={getSubscriptionStatusTone(sub.status)}>
|
||||
{getSubscriptionStatusLabel(sub.status)}
|
||||
</StatusBadge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Gauge, Link2, QrCode } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CopyButton } from "@/components/shared/copy-button";
|
||||
import { QrPreview } from "@/components/shared/qr-preview";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { buildSingleNodeUri } from "@/services/subscription";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
|
||||
interface ProxySubscriptionDetailsProps {
|
||||
sub: SubscriptionRecord;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDetailsProps) {
|
||||
if (sub.plan.type !== "PROXY") return null;
|
||||
|
||||
const used = Number(sub.trafficUsed);
|
||||
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
|
||||
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
|
||||
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{limit && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Gauge className="size-4 text-primary" /> 流量用量
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary tabular-nums">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percent} className="h-2.5" />
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<p className="rounded-xl bg-background/45 px-3 py-2">已用 <span className="font-semibold text-foreground">{formatBytes(used)}</span></p>
|
||||
<p className="rounded-xl bg-background/45 px-3 py-2">剩余 <span className="font-semibold text-foreground">{formatBytes(Math.max(0, limit - used))}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub.nodeClient ? (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Link2 className="size-4 text-primary" /> 导入信息
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/45 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
|
||||
</div>
|
||||
<CopyButton text={subUrl} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||
<QrCode className="size-3.5" /> 扫码导入
|
||||
</div>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
<QrPreview label="订阅 URL 二维码" value={subUrl} alt="订阅 URL 二维码" />
|
||||
{singleNodeUri && (
|
||||
<QrPreview
|
||||
label="单节点 URI 二维码"
|
||||
value={singleNodeUri}
|
||||
alt="单节点 URI 二维码"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-3 text-sm leading-6 text-muted-foreground">
|
||||
订阅节点正在准备中,分配完成后这里会展示订阅链接与二维码。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/app/(user)/subscriptions/_components/renewal-button.tsx
Normal file
145
src/app/(user)/subscriptions/_components/renewal-button.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw, WalletCards } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseRenewal } from "@/actions/user/purchase";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface RenewalConfig {
|
||||
durationDays: number;
|
||||
renewalPrice: number | null;
|
||||
renewalPricingMode: string;
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
}
|
||||
|
||||
function clampDuration(value: number, min: number, max: number, step: number) {
|
||||
const clamped = Math.min(max, Math.max(min, value));
|
||||
if (step <= 1) return clamped;
|
||||
const offset = clamped - min;
|
||||
return min + Math.round(offset / step) * step;
|
||||
}
|
||||
|
||||
export function RenewalButton({
|
||||
subscriptionId,
|
||||
config,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
config: RenewalConfig;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPerDay = config.renewalPricingMode === "PER_DAY";
|
||||
const unitDays = isPerDay ? 1 : (config.renewalDurationDays ?? config.durationDays);
|
||||
const minDays = config.renewalMinDays ?? unitDays;
|
||||
const maxDays = Math.max(minDays, config.renewalMaxDays ?? unitDays);
|
||||
const step = isPerDay ? 1 : unitDays;
|
||||
const [renewalDays, setRenewalDays] = useState(minDays);
|
||||
const totalPrice = useMemo(() => {
|
||||
const unitPrice = config.renewalPrice ?? 0;
|
||||
const amount = isPerDay ? unitPrice * renewalDays : unitPrice * Math.max(1, renewalDays / unitDays);
|
||||
return amount.toFixed(2);
|
||||
}, [config.renewalPrice, isPerDay, renewalDays, unitDays]);
|
||||
|
||||
async function handleRenewal() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await purchaseRenewal(subscriptionId, renewalDays);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
router.push(`/pay/${result.orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建续费订单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setRenewalDays(minDays);
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
续费
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<WalletCards className="size-3.5" /> RENEWAL
|
||||
</div>
|
||||
<DialogTitle>续费订阅</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择续费时长,支付成功后自动延长当前订阅有效期。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-4 flex justify-between text-sm">
|
||||
<span className="font-medium">续费时长</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{renewalDays} 天</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[renewalDays]}
|
||||
onValueChange={(values: number | readonly number[]) => {
|
||||
const value = Array.isArray(values) ? values[0] : values;
|
||||
setRenewalDays(clampDuration(value ?? minDays, minDays, maxDays, step));
|
||||
}}
|
||||
min={minDays}
|
||||
max={maxDays}
|
||||
step={step}
|
||||
disabled={maxDays <= minDays}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{minDays} 天</span>
|
||||
<span>{isPerDay ? `¥${(config.renewalPrice ?? 0).toFixed(2)}/天` : `¥${(config.renewalPrice ?? 0).toFixed(2)}/${unitDays}天`}</span>
|
||||
<span>{maxDays} 天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">本次应付</span>
|
||||
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleRenewal}
|
||||
disabled={loading || !config.renewalPrice}
|
||||
>
|
||||
{loading ? "创建中..." : "去支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { rotateSubscriptionAccess } from "@/actions/user/subscription-security";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
|
||||
export function ResetAccessButton({ subscriptionId }: { subscriptionId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
title="重置订阅访问?"
|
||||
description="我们会为这条订阅生成新的访问凭据。旧链接会失效,请在客户端重新导入。"
|
||||
confirmLabel="重置访问"
|
||||
successMessage="订阅访问已重置"
|
||||
errorMessage="重置失败"
|
||||
onConfirm={() => rotateSubscriptionAccess(subscriptionId)}
|
||||
onSuccess={() => router.refresh()}
|
||||
>
|
||||
<ShieldCheck className="size-3.5" />
|
||||
重置访问
|
||||
</ConfirmActionButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||
import { StreamingCredentialCard } from "../streaming-credential-card";
|
||||
|
||||
export function StreamingSubscriptionDetails({ sub }: { sub: SubscriptionRecord }) {
|
||||
if (sub.plan.type !== "STREAMING") return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Tv className="size-4 text-primary" /> 流媒体服务
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary">
|
||||
{sub.streamingSlot?.service.name ?? "待分配"}
|
||||
</span>
|
||||
</div>
|
||||
{sub.streamingSlot ? (
|
||||
<StreamingCredentialCard subscriptionId={sub.id} />
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">服务账号正在分配中,请稍后刷新查看。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface SubscriptionMetricsProps {
|
||||
activeCount: number;
|
||||
historyCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
["活跃", "activeCount"],
|
||||
["历史", "historyCount"],
|
||||
["全部", "totalCount"],
|
||||
] as const;
|
||||
|
||||
export function SubscriptionMetrics({
|
||||
activeCount,
|
||||
historyCount,
|
||||
totalCount,
|
||||
}: SubscriptionMetricsProps) {
|
||||
const values = { activeCount, historyCount, totalCount };
|
||||
|
||||
return (
|
||||
<section className="grid gap-2 sm:grid-cols-3">
|
||||
{metrics.map(([label, key]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded-xl border border-border bg-muted/20 px-4 py-3">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-lg font-semibold tracking-[-0.04em] tabular-nums">{values[key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, WalletCards } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { purchaseTrafficTopup } from "@/actions/user/purchase";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface TrafficTopupConfig {
|
||||
topupPricingMode: string;
|
||||
topupPricePerGb: number | null;
|
||||
topupFixedPrice: number | null;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
}
|
||||
|
||||
interface TrafficTopupDialogProps {
|
||||
subscriptionId: string;
|
||||
trafficPoolRemainingGb: number | null;
|
||||
config: TrafficTopupConfig;
|
||||
}
|
||||
|
||||
function getTopupBounds(trafficPoolRemainingGb: number | null, config: TrafficTopupConfig) {
|
||||
const min = Math.max(1, config.minTopupGb ?? 1);
|
||||
const configuredMax = config.maxTopupGb ?? 1000;
|
||||
const poolMax = trafficPoolRemainingGb == null
|
||||
? configuredMax
|
||||
: Math.max(0, Math.floor(trafficPoolRemainingGb));
|
||||
const max = Math.max(0, Math.min(configuredMax, poolMax));
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function TrafficTopupDialog({
|
||||
subscriptionId,
|
||||
trafficPoolRemainingGb,
|
||||
config,
|
||||
}: TrafficTopupDialogProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { min, max } = getTopupBounds(trafficPoolRemainingGb, config);
|
||||
const initialTopupGb = useMemo(() => {
|
||||
if (max <= 0) return min;
|
||||
return Math.min(Math.max(min, 10), max);
|
||||
}, [max, min]);
|
||||
const [topupGb, setTopupGb] = useState(initialTopupGb);
|
||||
const isFixedAmount = config.topupPricingMode === "FIXED_AMOUNT";
|
||||
const totalPrice = useMemo(() => {
|
||||
const amount = isFixedAmount
|
||||
? (config.topupFixedPrice ?? 0)
|
||||
: (config.topupPricePerGb ?? 0) * topupGb;
|
||||
return amount.toFixed(2);
|
||||
}, [config.topupFixedPrice, config.topupPricePerGb, isFixedAmount, topupGb]);
|
||||
const hasPrice = isFixedAmount
|
||||
? (config.topupFixedPrice ?? 0) > 0
|
||||
: (config.topupPricePerGb ?? 0) > 0;
|
||||
const disabled = max <= 0 || max < min || !hasPrice;
|
||||
|
||||
async function handleTopup() {
|
||||
if (disabled) {
|
||||
toast.error(hasPrice ? "当前套餐剩余额度不足,暂不可增流量" : "这款套餐暂未配置增流量价格");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const orderId = await purchaseTrafficTopup(subscriptionId, topupGb);
|
||||
router.push(`/pay/${orderId}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建增流量订单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTopupGb(initialTopupGb);
|
||||
setOpen(true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
增加流量
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="mb-1 inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<WalletCards className="size-3.5" /> TRAFFIC TOPUP
|
||||
</div>
|
||||
<DialogTitle>增加订阅流量</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择本次增加的流量,支付完成后自动写入当前订阅。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">单次可增范围</span>
|
||||
<span className="font-semibold tabular-nums">{disabled ? "暂无额度" : `${min}-${max} GB`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-4 flex justify-between text-sm">
|
||||
<span className="font-medium">本次增量</span>
|
||||
<span className="font-semibold text-primary tabular-nums">{topupGb} GB</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[topupGb]}
|
||||
onValueChange={(values: number | readonly number[]) => {
|
||||
const value = Array.isArray(values) ? values[0] : values;
|
||||
setTopupGb(value ?? min);
|
||||
}}
|
||||
min={min}
|
||||
max={Math.max(min, max)}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{isFixedAmount
|
||||
? `固定金额 ¥${(config.topupFixedPrice ?? 0).toFixed(2)}`
|
||||
: `按 ¥${(config.topupPricePerGb ?? 0).toFixed(2)}/GB 计费`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">本次应付</span>
|
||||
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleTopup}
|
||||
disabled={loading || disabled}
|
||||
>
|
||||
{loading ? "创建中..." : "去支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/app/(user)/subscriptions/page.tsx
Normal file
59
src/app/(user)/subscriptions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/(user)/subscriptions/streaming-credential-action.ts
Normal file
40
src/app/(user)/subscriptions/streaming-credential-action.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
68
src/app/(user)/subscriptions/streaming-credential-card.tsx
Normal file
68
src/app/(user)/subscriptions/streaming-credential-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/app/(user)/subscriptions/subscription-actions.tsx
Normal file
67
src/app/(user)/subscriptions/subscription-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/app/(user)/subscriptions/subscriptions-calculations.ts
Normal file
53
src/app/(user)/subscriptions/subscriptions-calculations.ts
Normal 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;
|
||||
}
|
||||
36
src/app/(user)/subscriptions/subscriptions-data.ts
Normal file
36
src/app/(user)/subscriptions/subscriptions-data.ts
Normal 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);
|
||||
}
|
||||
9
src/app/(user)/subscriptions/subscriptions-types.ts
Normal file
9
src/app/(user)/subscriptions/subscriptions-types.ts
Normal 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 } };
|
||||
};
|
||||
}>;
|
||||
67
src/app/(user)/support/[id]/page.tsx
Normal file
67
src/app/(user)/support/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto">发送回复</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SupportTicketThread } from "@/components/support/ticket-thread";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
src/app/(user)/support/page.tsx
Normal file
29
src/app/(user)/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/app/(user)/support/support-data.ts
Normal file
51
src/app/(user)/support/support-data.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user