mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add subscription push transfers
This commit is contained in:
@@ -62,6 +62,11 @@ export default async function AdminSettingsPage() {
|
||||
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||
inviteRewardRate: Number(config.inviteRewardRate),
|
||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
|
||||
subscriptionTransferFee: Number(config.subscriptionTransferFee),
|
||||
subscriptionTransferLimitPerCycle: config.subscriptionTransferLimitPerCycle,
|
||||
subscriptionTransferMinRemainingDays: config.subscriptionTransferMinRemainingDays,
|
||||
subscriptionTransferMinRemainingTrafficGb: config.subscriptionTransferMinRemainingTrafficGb,
|
||||
turnstileSiteKey: config.turnstileSiteKey,
|
||||
turnstileSecretConfigured: Boolean(config.turnstileSecretKey),
|
||||
smtpEnabled: config.smtpEnabled,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { ArrowRightLeft, Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||
@@ -72,6 +72,11 @@ interface AppConfig {
|
||||
inviteRewardEnabled: boolean;
|
||||
inviteRewardRate: number;
|
||||
inviteRewardCouponId: string | null;
|
||||
subscriptionTransferEnabled: boolean;
|
||||
subscriptionTransferFee: number;
|
||||
subscriptionTransferLimitPerCycle: number;
|
||||
subscriptionTransferMinRemainingDays: number;
|
||||
subscriptionTransferMinRemainingTrafficGb: number;
|
||||
turnstileSiteKey: string | null;
|
||||
turnstileSecretConfigured: boolean;
|
||||
smtpEnabled: boolean;
|
||||
@@ -102,6 +107,7 @@ type SettingsSectionValue =
|
||||
| "auth"
|
||||
| "email"
|
||||
| "invite"
|
||||
| "transfer"
|
||||
| "turnstile"
|
||||
| "notices";
|
||||
|
||||
@@ -115,6 +121,7 @@ const settingsNavItems = [
|
||||
{ value: "auth", label: "注册" },
|
||||
{ value: "email", label: "邮件" },
|
||||
{ value: "invite", label: "邀请" },
|
||||
{ value: "transfer", label: "转让" },
|
||||
{ value: "turnstile", label: "验证" },
|
||||
{ value: "notices", label: "公告" },
|
||||
] satisfies Array<{ value: SettingsSectionValue; label: string }>;
|
||||
@@ -168,6 +175,7 @@ function initialToggleValues(config: AppConfig): ToggleValues {
|
||||
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
|
||||
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
|
||||
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
|
||||
smtpEnabled: config.smtpEnabled,
|
||||
smtpSecure: config.smtpSecure,
|
||||
};
|
||||
@@ -835,6 +843,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-transfer" className={sectionClass("transfer")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<ArrowRightLeft className="size-4 text-primary" /> 套餐 Push
|
||||
<InlineHelp align="start">控制用户间套餐转让。</InlineHelp>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionTransferEnabled">允许 Push</Label>
|
||||
{renderImmediateToggle("subscriptionTransferEnabled", {
|
||||
id: "subscriptionTransferEnabled",
|
||||
trueLabel: "允许",
|
||||
falseLabel: "关闭",
|
||||
ariaLabel: "允许套餐 Push",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelWithHelp htmlFor="subscriptionTransferFee" help="固定手续费,可填 0。">
|
||||
转让费(元)
|
||||
</LabelWithHelp>
|
||||
<Input
|
||||
id="subscriptionTransferFee"
|
||||
name="subscriptionTransferFee"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
step="0.01"
|
||||
defaultValue={config.subscriptionTransferFee}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelWithHelp htmlFor="subscriptionTransferLimitPerCycle" help="同一订阅周期内最多成功转让次数,0 表示禁止。">
|
||||
单周期次数
|
||||
</LabelWithHelp>
|
||||
<Input
|
||||
id="subscriptionTransferLimitPerCycle"
|
||||
name="subscriptionTransferLimitPerCycle"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={config.subscriptionTransferLimitPerCycle}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingDays" help="低于这个剩余天数时不能转让,0 表示不限制。">
|
||||
剩余天数下限
|
||||
</LabelWithHelp>
|
||||
<Input
|
||||
id="subscriptionTransferMinRemainingDays"
|
||||
name="subscriptionTransferMinRemainingDays"
|
||||
type="number"
|
||||
min={0}
|
||||
max={3650}
|
||||
step={1}
|
||||
defaultValue={config.subscriptionTransferMinRemainingDays}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingTrafficGb" help="代理套餐剩余流量低于此值时不能转让,0 表示不限制。">
|
||||
剩余流量下限(GB)
|
||||
</LabelWithHelp>
|
||||
<Input
|
||||
id="subscriptionTransferMinRemainingTrafficGb"
|
||||
name="subscriptionTransferMinRemainingTrafficGb"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000000}
|
||||
step={1}
|
||||
defaultValue={config.subscriptionTransferMinRemainingTrafficGb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-turnstile" className={sectionClass("turnstile")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, Trash2 } from "lucide-react";
|
||||
import { deleteAdminSubscriptionTransfer } from "@/actions/admin/subscription-transfers";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export interface AdminSubscriptionTransferItem {
|
||||
id: string;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
statusTone: StatusTone;
|
||||
planName: string;
|
||||
planTypeLabel: string;
|
||||
senderLabel: string;
|
||||
recipientLabel: string;
|
||||
feeLabel: string;
|
||||
feePayerLabel: string;
|
||||
feeChargedLabel: string;
|
||||
feeRefundedLabel: string;
|
||||
cycleStartedAtLabel: string;
|
||||
createdAtLabel: string;
|
||||
expiresAtLabel: string;
|
||||
acceptedAtLabel: string;
|
||||
endDateLabel: string;
|
||||
trafficLabel: string;
|
||||
nodeLabel: string;
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-words text-sm font-medium leading-5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferDetailDialog({ item }: { item: AdminSubscriptionTransferItem }) {
|
||||
const details = [
|
||||
{ label: "套餐", value: `${item.planName} · ${item.planTypeLabel}` },
|
||||
{ label: "状态", value: item.statusLabel },
|
||||
{ label: "转出方", value: item.senderLabel },
|
||||
{ label: "接收方", value: item.recipientLabel },
|
||||
{ label: "费用", value: item.feeLabel },
|
||||
{ label: "承担方", value: item.feePayerLabel },
|
||||
{ label: "扣费", value: item.feeChargedLabel },
|
||||
{ label: "退款", value: item.feeRefundedLabel },
|
||||
{ label: "周期起点", value: item.cycleStartedAtLabel },
|
||||
{ label: "发起时间", value: item.createdAtLabel },
|
||||
{ label: "确认截止", value: item.expiresAtLabel },
|
||||
{ label: "接收时间", value: item.acceptedAtLabel },
|
||||
{ label: "套餐到期", value: item.endDateLabel },
|
||||
{ label: "剩余流量", value: item.trafficLabel },
|
||||
{ label: "节点", value: item.nodeLabel },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
|
||||
<Eye className="size-3.5" />
|
||||
详情
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[40rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle>Push 详情</DialogTitle>
|
||||
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
|
||||
</div>
|
||||
<DialogDescription>{item.senderLabel} {"->"} {item.recipientLabel}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{details.map((detail) => (
|
||||
<DetailItem key={detail.label} {...detail} />
|
||||
))}
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionTransfersTable({ transfers }: { transfers: AdminSubscriptionTransferItem[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
if (transfers.length === 0) {
|
||||
return (
|
||||
<div className="surface-card rounded-xl px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无套餐 Push 记录
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||
{transfers.map((item) => {
|
||||
const deletingPending = item.status === "PENDING";
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)_auto] lg:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate font-semibold">{item.planName}</h3>
|
||||
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.senderLabel} {"->"} {item.recipientLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge>{item.feeLabel}</StatusBadge>
|
||||
<StatusBadge>{item.feePayerLabel}</StatusBadge>
|
||||
<StatusBadge>{item.createdAtLabel}</StatusBadge>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
<TransferDetailDialog item={item} />
|
||||
<ConfirmActionButton
|
||||
title="删除这条 Push 记录?"
|
||||
description={
|
||||
deletingPending
|
||||
? "这条 Push 仍在待接收状态。删除会取消本次 Push、恢复转出方套餐,并退回已扣手续费。"
|
||||
: "只删除管理端历史记录,不会回滚已完成的套餐转移。"
|
||||
}
|
||||
confirmLabel="删除记录"
|
||||
successMessage={deletingPending ? "Push 已取消并删除" : "Push 记录已删除"}
|
||||
errorMessage="删除 Push 记录失败"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onConfirm={async () => {
|
||||
await deleteAdminSubscriptionTransfer(item.id);
|
||||
}}
|
||||
onSuccess={() => router.refresh()}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal file
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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 type { StatusTone } from "@/components/shared/status-badge";
|
||||
import { formatBytes, formatDate } from "@/lib/utils";
|
||||
import {
|
||||
getSubscriptionTransferFeePayerLabel,
|
||||
getSubscriptionTransferStatusLabel,
|
||||
} from "@/lib/domain-labels";
|
||||
import { getAdminSubscriptionTransfers, type AdminSubscriptionTransferRow } from "./transfers-data";
|
||||
import {
|
||||
SubscriptionTransfersTable,
|
||||
type AdminSubscriptionTransferItem,
|
||||
} from "./_components/subscription-transfers-table";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "套餐 Push",
|
||||
description: "查看用户间套餐转让记录。",
|
||||
};
|
||||
|
||||
const statusTones: Record<string, StatusTone> = {
|
||||
PENDING: "warning",
|
||||
ACCEPTED: "success",
|
||||
REJECTED: "neutral",
|
||||
CANCELLED: "neutral",
|
||||
EXPIRED: "danger",
|
||||
};
|
||||
|
||||
function money(value: unknown) {
|
||||
return `¥${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function userLabel(user: { name: string | null; email: string }) {
|
||||
return user.name ? `${user.name} · ${user.email}` : user.email;
|
||||
}
|
||||
|
||||
function trafficLabel(row: AdminSubscriptionTransferRow) {
|
||||
const sub = row.subscription;
|
||||
if (sub.plan.type !== "PROXY") return "不涉及流量限制";
|
||||
if (!sub.trafficLimit) return "不限流量";
|
||||
const remaining = sub.trafficLimit > sub.trafficUsed ? sub.trafficLimit - sub.trafficUsed : BigInt(0);
|
||||
return `${formatBytes(remaining)} / ${formatBytes(sub.trafficLimit)}`;
|
||||
}
|
||||
|
||||
function nodeLabel(row: AdminSubscriptionTransferRow) {
|
||||
const client = row.subscription.nodeClient;
|
||||
if (!client) return row.subscription.streamingSlot?.service.name ?? "无节点";
|
||||
return `${client.inbound.server.name} · ${client.inbound.tag}`;
|
||||
}
|
||||
|
||||
function toItem(row: AdminSubscriptionTransferRow): AdminSubscriptionTransferItem {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
statusLabel: getSubscriptionTransferStatusLabel(row.status),
|
||||
statusTone: statusTones[row.status] ?? "neutral",
|
||||
planName: row.plan.name,
|
||||
planTypeLabel: row.plan.type === "PROXY" ? "代理" : "流媒体",
|
||||
senderLabel: userLabel(row.sender),
|
||||
recipientLabel: userLabel(row.recipient),
|
||||
feeLabel: money(row.feeAmount),
|
||||
feePayerLabel: getSubscriptionTransferFeePayerLabel(row.feePayer),
|
||||
feeChargedLabel: row.feeChargedAt ? formatDate(row.feeChargedAt) : "未扣费",
|
||||
feeRefundedLabel: row.feeRefundedAt ? formatDate(row.feeRefundedAt) : "未退款",
|
||||
cycleStartedAtLabel: formatDate(row.cycleStartedAt),
|
||||
createdAtLabel: formatDate(row.createdAt),
|
||||
expiresAtLabel: formatDate(row.expiresAt),
|
||||
acceptedAtLabel: row.acceptedAt ? formatDate(row.acceptedAt) : "未接收",
|
||||
endDateLabel: formatDate(row.subscription.endDate),
|
||||
trafficLabel: trafficLabel(row),
|
||||
nodeLabel: nodeLabel(row),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminSubscriptionTransfersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { transfers, total, page, pageSize, filters } = await getAdminSubscriptionTransfers(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="套餐 Push"
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索邮箱、昵称、套餐名"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "待接收", value: "PENDING" },
|
||||
{ label: "已接收", value: "ACCEPTED" },
|
||||
{ label: "已拒收", value: "REJECTED" },
|
||||
{ label: "已取消", value: "CANCELLED" },
|
||||
{ label: "已过期", value: "EXPIRED" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<SubscriptionTransfersTable transfers={transfers.map(toItem)} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import { processExpiredSubscriptionTransfers } from "@/services/subscription-transfer";
|
||||
|
||||
const adminTransferInclude = {
|
||||
plan: true,
|
||||
subscription: {
|
||||
include: {
|
||||
plan: true,
|
||||
nodeClient: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
streamingSlot: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sender: { select: { id: true, email: true, name: true } },
|
||||
recipient: { select: { id: true, email: true, name: true } },
|
||||
} satisfies Prisma.SubscriptionTransferInclude;
|
||||
|
||||
export type AdminSubscriptionTransferRow = Prisma.SubscriptionTransferGetPayload<{
|
||||
include: typeof adminTransferInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminSubscriptionTransfers(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
await processExpiredSubscriptionTransfers();
|
||||
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { status: status as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ senderEmail: { contains: q } },
|
||||
{ recipientEmail: { contains: q } },
|
||||
{ plan: { name: { contains: q } } },
|
||||
{ sender: { name: { contains: q } } },
|
||||
{ recipient: { name: { contains: q } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.SubscriptionTransferWhereInput;
|
||||
|
||||
const [transfers, total] = await Promise.all([
|
||||
prisma.subscriptionTransfer.findMany({
|
||||
where,
|
||||
include: adminTransferInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.subscriptionTransfer.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
transfers,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters: { q, status },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user