Initial commit

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

View File

@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
export const metadata: Metadata = {
title: "订阅详情",
description: "查看订阅生命周期、资源绑定和流量日志。",
};
export default async function AdminSubscriptionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const data = await getAdminSubscriptionDetail(id);
if (!data) {
notFound();
}
const { subscription, auditLogs, trafficLogs } = data;
return (
<PageShell>
<PageHeader
eyebrow="订阅详情"
title={subscription.plan.name}
description={subscription.user.email}
/>
<SubscriptionDetailCards subscription={subscription} showClientEmail />
<SubscriptionTimelineSection logs={auditLogs} />
<TrafficLogList logs={trafficLogs} />
</PageShell>
);
}

View File

@@ -0,0 +1,58 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const adminSubscriptionDetailInclude = {
user: true,
plan: true,
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
streamingSlot: {
include: {
service: true,
},
},
} satisfies Prisma.UserSubscriptionInclude;
export type AdminSubscriptionDetail = Prisma.UserSubscriptionGetPayload<{
include: typeof adminSubscriptionDetailInclude;
}>;
export async function getAdminSubscriptionDetail(subscriptionId: string) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: subscriptionId },
include: adminSubscriptionDetailInclude,
});
if (!subscription) {
return null;
}
const [auditLogs, trafficLogs] = await Promise.all([
prisma.auditLog.findMany({
where: {
targetType: "UserSubscription",
targetId: subscription.id,
},
orderBy: { createdAt: "desc" },
take: 100,
}),
subscription.nodeClient
? prisma.trafficLog.findMany({
where: {
clientId: subscription.nodeClient.id,
},
orderBy: { timestamp: "desc" },
take: 30,
})
: Promise.resolve([]),
]);
return { subscription, auditLogs, trafficLogs };
}

View File

@@ -0,0 +1,158 @@
import Link from "next/link";
import { batchSubscriptionOperation } from "@/actions/admin/subscriptions";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
} from "@/components/shared/domain-badges";
import { formatBytes, formatDateShort } from "@/lib/utils";
import { AdminSubscriptionActions } from "../subscription-actions";
import type { StreamingServiceOption } from "../streaming-slot-dialog";
import type { AdminSubscriptionRow } from "../subscriptions-data";
interface SubscriptionsTableProps {
subscriptions: AdminSubscriptionRow[];
streamingServices: StreamingServiceOption[];
}
function SubscriptionResource({ subscription }: { subscription: AdminSubscriptionRow }) {
if (subscription.plan.type === "PROXY") {
return (
<div className="space-y-1">
<p>{subscription.nodeClient?.inbound.server.name ?? "未分配节点"}</p>
<p className="text-xs text-muted-foreground">
{subscription.nodeClient
? `${subscription.nodeClient.inbound.protocol} · ${subscription.nodeClient.inbound.tag}`
: "暂无客户端"}
</p>
</div>
);
}
return (
<div className="space-y-1">
<p>{subscription.streamingSlot?.service.name ?? "未分配服务"}</p>
<p className="text-xs text-muted-foreground">
{subscription.streamingSlot ? "已占用槽位" : "暂无槽位"}
</p>
</div>
);
}
function SubscriptionTraffic({ subscription }: { subscription: AdminSubscriptionRow }) {
if (subscription.plan.type !== "PROXY") return <span className="text-muted-foreground"></span>;
const limit = subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "无限";
const used = formatBytes(subscription.trafficUsed);
return <span>{used} / {limit}</span>;
}
export function SubscriptionsTable({
subscriptions,
streamingServices,
}: SubscriptionsTableProps) {
return (
<DataTableShell
isEmpty={subscriptions.length === 0}
emptyTitle="暂无订阅记录"
emptyDescription="用户完成购买并开通后,订阅会出现在这里。"
toolbar={
<BatchActionBar
id="subscription-batch-form"
action={batchSubscriptionOperation}
className="rounded-none bg-transparent"
>
<BatchActionButton value="suspend"></BatchActionButton>
<BatchActionButton value="activate"></BatchActionButton>
<BatchActionButton value="delete" destructive>
</BatchActionButton>
</BatchActionBar>
}
>
<DataTable aria-label="订阅列表" className="min-w-[1080px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{subscriptions.map((subscription) => (
<DataTableRow key={subscription.id}>
<DataTableCell>
<input
form="subscription-batch-form"
type="checkbox"
name="subscriptionIds"
value={subscription.id}
aria-label={`选择订阅 ${subscription.id}`}
/>
</DataTableCell>
<DataTableCell className="max-w-56 whitespace-normal break-all">
<p className="font-medium">{subscription.user.email}</p>
<p className="text-xs text-muted-foreground">
{subscription.user.name || "未设置昵称"}
</p>
</DataTableCell>
<DataTableCell className="max-w-52 whitespace-normal break-words">
<Link
href={`/admin/subscriptions/${subscription.id}`}
className="font-medium hover:underline"
>
{subscription.plan.name}
</Link>
</DataTableCell>
<DataTableCell>
<SubscriptionTypeBadge type={subscription.plan.type} />
</DataTableCell>
<DataTableCell>
<SubscriptionResource subscription={subscription} />
</DataTableCell>
<DataTableCell>
<SubscriptionTraffic subscription={subscription} />
</DataTableCell>
<DataTableCell>
<p>{formatDateShort(subscription.startDate)}</p>
<p className="text-xs text-muted-foreground">
{formatDateShort(subscription.endDate)}
</p>
</DataTableCell>
<DataTableCell>
<SubscriptionStatusBadge status={subscription.status} />
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<AdminSubscriptionActions
subscriptionId={subscription.id}
status={subscription.status}
type={subscription.plan.type}
streamingServices={streamingServices}
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import { AdminFilterBar } from "@/components/admin/filter-bar";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination";
import { SubscriptionsTable } from "./_components/subscriptions-table";
import { getAdminSubscriptions } from "./subscriptions-data";
export const metadata: Metadata = {
title: "订阅管理",
description: "管理订阅状态、客户端绑定与流媒体槽位。",
};
export default async function AdminSubscriptionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { subscriptions, total, page, pageSize, filters, streamingServices } =
await getAdminSubscriptions(await searchParams);
return (
<PageShell>
<PageHeader
eyebrow="商品与订单"
title="订阅管理"
/>
<AdminFilterBar
q={filters.q}
searchPlaceholder="搜索用户邮箱、昵称、套餐名"
selects={[
{
name: "status",
value: filters.status,
options: [
{ label: "全部状态", value: "" },
{ label: "活跃", value: "ACTIVE" },
{ label: "暂停", value: "SUSPENDED" },
{ label: "过期", value: "EXPIRED" },
{ label: "取消", value: "CANCELLED" },
],
},
{
name: "type",
value: filters.type,
options: [
{ label: "全部类型", value: "" },
{ label: "代理", value: "PROXY" },
{ label: "流媒体", value: "STREAMING" },
],
},
]}
/>
<SubscriptionsTable subscriptions={subscriptions} streamingServices={streamingServices} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { reassignStreamingSlot } from "@/actions/admin/subscriptions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getErrorMessage } from "@/lib/errors";
import { toast } from "sonner";
export interface StreamingServiceOption {
id: string;
name: string;
usedSlots: number;
maxSlots: number;
}
export function StreamingSlotDialog({
subscriptionId,
services,
}: {
subscriptionId: string;
services: StreamingServiceOption[];
}) {
const [open, setOpen] = useState(false);
const [serviceId, setServiceId] = useState(services[0]?.id ?? "");
const [saving, setSaving] = useState(false);
async function handleAssign() {
if (!serviceId) {
toast.error("请选择目标服务");
return;
}
setSaving(true);
try {
await reassignStreamingSlot(subscriptionId, serviceId);
toast.success("槽位已调配");
setOpen(false);
} catch (error) {
toast.error(getErrorMessage(error, "调配失败"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" variant="outline" />}>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Select value={serviceId} onValueChange={(value) => setServiceId(value ?? "")}>
<SelectTrigger>
<SelectValue placeholder="选择目标服务">
{(v) => {
const m = services.find((s) => s.id === v);
return m ? `${m.name} · ${m.usedSlots}/${m.maxSlots}` : "选择目标服务";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name} · {service.usedSlots}/{service.maxSlots}
</SelectItem>
))}
</SelectContent>
</Select>
<Button className="w-full" onClick={() => void handleAssign()} disabled={saving}>
{saving ? "处理中..." : "确认调配"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { Button } from "@/components/ui/button";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { getErrorMessage } from "@/lib/errors";
import {
activateSubscription,
deleteSubscriptionPermanently,
suspendSubscription,
} from "@/actions/admin/subscriptions";
import { toast } from "sonner";
import {
StreamingSlotDialog,
type StreamingServiceOption,
} from "./streaming-slot-dialog";
export function AdminSubscriptionActions({
subscriptionId,
status,
type,
streamingServices,
}: {
subscriptionId: string;
status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED";
type: "PROXY" | "STREAMING";
streamingServices: StreamingServiceOption[];
}) {
return (
<div className="flex flex-wrap gap-2">
{type === "STREAMING" && streamingServices.length > 0 && (
<StreamingSlotDialog
subscriptionId={subscriptionId}
services={streamingServices}
/>
)}
{status === "ACTIVE" && (
<Button
size="sm"
variant="outline"
onClick={() => {
void (async () => {
try {
await suspendSubscription(subscriptionId);
toast.success("订阅已暂停");
} catch (error) {
toast.error(getErrorMessage(error, "暂停失败"));
}
})();
}}
>
</Button>
)}
{status === "SUSPENDED" && (
<Button
size="sm"
onClick={() => {
void (async () => {
try {
await activateSubscription(subscriptionId);
toast.success("订阅已恢复");
} catch (error) {
toast.error(getErrorMessage(error, "恢复失败"));
}
})();
}}
>
</Button>
)}
<ConfirmActionButton
size="sm"
variant="destructive"
title="彻底删除这个订阅?"
description="会同步删除远端客户端,并清理本地记录与相关订单。此操作无法恢复。"
confirmLabel="删除订阅"
successMessage="订阅已删除"
errorMessage="删除失败"
onConfirm={() => deleteSubscriptionPermanently(subscriptionId)}
>
</ConfirmActionButton>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import type { StreamingServiceOption } from "./streaming-slot-dialog";
const adminSubscriptionInclude = {
user: true,
plan: true,
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
streamingSlot: {
include: {
service: true,
},
},
} satisfies Prisma.UserSubscriptionInclude;
export type AdminSubscriptionRow = Prisma.UserSubscriptionGetPayload<{
include: typeof adminSubscriptionInclude;
}>;
export async function getAdminSubscriptions(
searchParams: Record<string, string | string[] | undefined>,
) {
const { page, skip, pageSize } = parsePage(searchParams);
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
const status = typeof searchParams.status === "string" ? searchParams.status : "";
const type = typeof searchParams.type === "string" ? searchParams.type : "";
const where = {
...(status ? { status: status as "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED" } : {}),
...(type ? { plan: { type: type as "PROXY" | "STREAMING" } } : {}),
...(q
? {
OR: [
{ user: { email: { contains: q, mode: "insensitive" as const } } },
{ user: { name: { contains: q, mode: "insensitive" as const } } },
{ plan: { name: { contains: q, mode: "insensitive" as const } } },
],
}
: {}),
} satisfies Prisma.UserSubscriptionWhereInput;
const [subscriptions, total, streamingServices] = await Promise.all([
prisma.userSubscription.findMany({
where,
include: adminSubscriptionInclude,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.userSubscription.count({ where }),
prisma.streamingService.findMany({
where: { isActive: true },
select: {
id: true,
name: true,
usedSlots: true,
maxSlots: true,
},
orderBy: { name: "asc" },
}),
]);
return {
subscriptions,
total,
page,
pageSize,
filters: { q, status, type },
streamingServices: streamingServices satisfies StreamingServiceOption[],
};
}