feat: add subscription push transfers

This commit is contained in:
JetSprow
2026-05-01 04:39:15 +10:00
parent b2a50514a4
commit e718d5edab
23 changed files with 2049 additions and 27 deletions

View File

@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import { ArrowRightLeft, WalletCards } from "lucide-react";
import { getActiveSession } from "@/lib/require-auth";
import { formatBytes, formatDate } from "@/lib/utils";
import { getSubscriptionTransferFeePayerLabel, getSubscriptionTransferStatusLabel } from "@/lib/domain-labels";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import type { StatusTone } from "@/components/shared/status-badge";
import { getSubscriptionPushPageData, type UserSubscriptionTransferRow } from "./push-data";
import { SubscriptionPushForm } from "./_components/subscription-push-form";
import { SubscriptionPushList, type SubscriptionTransferItem } from "./_components/subscription-push-list";
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(transfer: UserSubscriptionTransferRow) {
const sub = transfer.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 toTransferItem(transfer: UserSubscriptionTransferRow, userId: string): SubscriptionTransferItem {
return {
id: transfer.id,
role: transfer.recipientId === userId ? "incoming" : "outgoing",
status: transfer.status,
statusLabel: getSubscriptionTransferStatusLabel(transfer.status),
statusTone: statusTones[transfer.status] ?? "neutral",
planName: transfer.plan.name,
planTypeLabel: transfer.plan.type === "PROXY" ? "代理" : "流媒体",
senderLabel: userLabel(transfer.sender),
recipientLabel: userLabel(transfer.recipient),
feeLabel: money(transfer.feeAmount),
feePayerLabel: getSubscriptionTransferFeePayerLabel(transfer.feePayer),
createdAtLabel: formatDate(transfer.createdAt),
expiresAtLabel: formatDate(transfer.expiresAt),
acceptedAtLabel: transfer.acceptedAt ? formatDate(transfer.acceptedAt) : null,
endDateLabel: formatDate(transfer.subscription.endDate),
trafficLabel: trafficLabel(transfer),
};
}
export default async function SubscriptionPushPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await getActiveSession();
const [params, data] = await Promise.all([
searchParams,
getSubscriptionPushPageData(session!.user.id),
]);
const initialSubscriptionId = typeof params.subscriptionId === "string" ? params.subscriptionId : undefined;
const transferItems = data.transfers.map((transfer) => toTransferItem(transfer, session!.user.id));
return (
<PageShell>
<PageHeader
eyebrow="订阅管理"
title="套餐 Push"
description="将未到期套餐转给其他用户,对方需在 24 小时内确认。"
/>
<section className="grid gap-4 sm:grid-cols-3">
<div className="surface-card rounded-xl p-4">
<p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<WalletCards className="size-4 text-primary" />
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{money(data.walletBalance)}</p>
</div>
<div className="surface-card rounded-xl p-4">
<p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<ArrowRightLeft className="size-4 text-primary" />
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{money(data.config.feeAmount)}</p>
</div>
<div className="surface-card rounded-xl p-4">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{data.config.limitPerCycle}</p>
</div>
</section>
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
<SubscriptionPushForm
subscriptions={data.subscriptions}
config={data.config}
initialSubscriptionId={initialSubscriptionId}
/>
<div className="space-y-4">
<SectionHeader title="Push 记录" />
<SubscriptionPushList transfers={transferItems} />
</div>
</section>
</PageShell>
);
}