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:
40
src/app/(admin)/admin/subscriptions/[id]/page.tsx
Normal file
40
src/app/(admin)/admin/subscriptions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
59
src/app/(admin)/admin/subscriptions/page.tsx
Normal file
59
src/app/(admin)/admin/subscriptions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
88
src/app/(admin)/admin/subscriptions/subscription-actions.tsx
Normal file
88
src/app/(admin)/admin/subscriptions/subscription-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/app/(admin)/admin/subscriptions/subscriptions-data.ts
Normal file
80
src/app/(admin)/admin/subscriptions/subscriptions-data.ts
Normal 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[],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user