feat: add wallet and recharge cards

This commit is contained in:
JetSprow
2026-05-01 02:31:29 +10:00
parent 6d6489817d
commit 018bed3f36
32 changed files with 2058 additions and 170 deletions

View File

@@ -0,0 +1,124 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { createAdminRechargeCards } from "@/actions/admin/recharge-cards";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getErrorMessage } from "@/lib/errors";
interface PlanOption {
id: string;
name: string;
type: "PROXY" | "STREAMING";
remainingCount: number | null;
}
export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
const router = useRouter();
const [type, setType] = useState<"BALANCE" | "PLAN">("BALANCE");
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
const [pending, startTransition] = useTransition();
const selectedPlan = plans.find((plan) => plan.id === planId) ?? null;
const planSoldOut = type === "PLAN" && selectedPlan?.remainingCount === 0;
return (
<form
className="form-panel space-y-4"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
formData.set("type", type);
if (type === "PLAN") formData.set("planId", planId);
startTransition(async () => {
try {
await createAdminRechargeCards(formData);
toast.success("充值卡已生成");
form.reset();
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成充值卡失败"));
}
});
}}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<BooleanToggle
value={type === "PLAN"}
onChange={(value) => setType(value ? "PLAN" : "BALANCE")}
trueLabel="套餐卡"
falseLabel="余额卡"
ariaLabel="充值卡类型"
className="w-36"
/>
</div>
<input type="hidden" name="type" value={type} />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recharge-card-quantity"></Label>
<Input
id="recharge-card-quantity"
name="quantity"
type="number"
min="1"
max={type === "PLAN" && selectedPlan?.remainingCount != null ? Math.min(200, selectedPlan.remainingCount) : 200}
defaultValue={1}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="recharge-card-batch"></Label>
<Input id="recharge-card-batch" name="batchName" placeholder="五月活动" />
</div>
</div>
{type === "BALANCE" ? (
<div className="space-y-2">
<Label htmlFor="recharge-card-amount"></Label>
<Input id="recharge-card-amount" name="balanceAmount" type="number" min="0.01" step="0.01" placeholder="100.00" required />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Select value={planId} onValueChange={(value) => setPlanId(value ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择套餐" />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} · {plan.type === "PROXY" ? "代理" : "流媒体"} · {plan.remainingCount ?? "不限"}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPlan && (
<p className="text-xs text-muted-foreground">
{selectedPlan.remainingCount == null ? "库存不限" : `当前可生成 ${selectedPlan.remainingCount}`}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="recharge-card-expires"></Label>
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" />
</div>
<Button className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
{pending ? "生成中..." : planSoldOut ? "套餐已售罄" : "生成充值卡"}
</Button>
</form>
);
}

View File

@@ -1,7 +1,22 @@
import { prisma } from "@/lib/prisma";
import { getPlanAvailability } from "@/services/plan-availability";
function getRechargeCardPlanRemaining(
planType: "PROXY" | "STREAMING",
availability: Awaited<ReturnType<typeof getPlanAvailability>>,
) {
const limits: number[] = [];
if (availability.remainingByPlanLimit != null) {
limits.push(availability.remainingByPlanLimit);
}
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
limits.push(availability.remainingByServiceCapacity);
}
return limits.length > 0 ? Math.min(...limits) : null;
}
export async function getCommerceData() {
const [coupons, promotions] = await Promise.all([
const [coupons, promotions, rechargeCards, planRows] = await Promise.all([
prisma.coupon.findMany({
orderBy: { createdAt: "desc" },
include: { _count: { select: { orders: true, grants: true } } },
@@ -11,7 +26,39 @@ export async function getCommerceData() {
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
take: 30,
}),
prisma.rechargeCard.findMany({
orderBy: { createdAt: "desc" },
include: {
plan: { select: { name: true, type: true } },
redeemedBy: { select: { email: true } },
},
take: 50,
}),
prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
select: {
id: true,
name: true,
type: true,
totalLimit: true,
perUserLimit: true,
streamingServiceId: true,
},
}),
]);
return { coupons, promotions };
const plans = await Promise.all(
planRows.map(async (plan) => {
const availability = await getPlanAvailability(plan);
return {
id: plan.id,
name: plan.name,
type: plan.type,
remainingCount: getRechargeCardPlanRemaining(plan.type, availability),
};
}),
);
return { coupons, promotions, rechargeCards, plans };
}

View File

@@ -1,16 +1,19 @@
import type { Metadata } from "next";
import { Gift, Sparkles } from "lucide-react";
import { Gift, Sparkles, WalletCards } from "lucide-react";
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import { CopyButton } from "@/components/shared/copy-button";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDate } from "@/lib/utils";
import { getCommerceData } from "./commerce-data";
import { CommerceToggleButton } from "./_components/commerce-actions";
import { DiscountTypeSelect } from "./_components/discount-type-select";
import { RechargeCardForm } from "./_components/recharge-card-form";
function formatCouponDiscount(type: string, value: unknown) {
const numericValue = Number(value);
@@ -26,7 +29,7 @@ export const metadata: Metadata = {
};
export default async function AdminCommercePage() {
const { coupons, promotions } = await getCommerceData();
const { coupons, promotions, rechargeCards, plans } = await getCommerceData();
return (
<PageShell>
@@ -39,6 +42,7 @@ export default async function AdminCommercePage() {
<TabsList variant="line" className="surface-card p-1">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="manage"></TabsTrigger>
<TabsTrigger value="cards"></TabsTrigger>
</TabsList>
<TabsContent value="create">
@@ -175,6 +179,57 @@ export default async function AdminCommercePage() {
</div>
</section>
</TabsContent>
<TabsContent value="cards" className="space-y-6">
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
<RechargeCardForm plans={plans} />
<div className="space-y-4">
<SectionHeader title="最近充值卡" />
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{rechargeCards.map((card) => (
<article key={card.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<WalletCards className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-mono font-semibold">{card.code}</h3>
<StatusBadge tone={card.status === "UNUSED" ? "success" : "neutral"}>
{card.status === "UNUSED"
? "未使用"
: card.status === "REDEEMED"
? "已兑换"
: card.status === "EXPIRED"
? "已过期"
: "已停用"}
</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{card.type === "BALANCE"
? `余额卡 ¥${Number(card.balanceAmount ?? 0).toFixed(2)}`
: `套餐卡 ${card.plan?.name ?? "套餐已删除"}`}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
{card.redeemedBy && <StatusBadge tone="info">{card.redeemedBy.email}</StatusBadge>}
{card.expiresAt && <StatusBadge> {formatDate(card.expiresAt)}</StatusBadge>}
</div>
<div className="flex justify-start lg:justify-end">
<CopyButton text={card.code} />
</div>
</article>
))}
{rechargeCards.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
</section>
</TabsContent>
</Tabs>
</PageShell>
);

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
import { AlertTriangle, Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
import { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
@@ -80,6 +80,7 @@ export function PaymentConfigItem({
const [enabled, setEnabled] = useState(initialEnabled);
const [saving, setSaving] = useState(false);
const [statusSaving, setStatusSaving] = useState(false);
const [balanceDisableOpen, setBalanceDisableOpen] = useState(false);
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
buildInitialCheckboxValues(fields, currentConfig),
);
@@ -100,7 +101,7 @@ export function PaymentConfigItem({
});
}
async function handleStatusToggle(nextEnabled: boolean) {
async function commitStatusToggle(nextEnabled: boolean) {
if (statusSaving || enabled === nextEnabled) return;
const previousEnabled = enabled;
@@ -123,6 +124,15 @@ export function PaymentConfigItem({
}
}
async function handleStatusToggle(nextEnabled: boolean) {
if (statusSaving || enabled === nextEnabled) return;
if (provider === "balance" && !nextEnabled) {
setBalanceDisableOpen(true);
return;
}
await commitStatusToggle(nextEnabled);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (saving || statusSaving) return;
@@ -160,129 +170,168 @@ export function PaymentConfigItem({
}
return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
<>
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => (
<StatusBadge key={label} tone="info">{label}</StatusBadge>
))}
</div>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => (
<StatusBadge key={label} tone="info">{label}</StatusBadge>
))}
</div>
)}
</div>
</div>
<div className="flex items-center justify-start lg:justify-end">
<BooleanToggle
className="w-full lg:w-40"
value={enabled}
onChange={(value) => void handleStatusToggle(value)}
trueLabel="启用"
falseLabel="停用"
ariaLabel={`${providerName}状态`}
disabled={saving || statusSaving}
/>
</div>
<div className="flex items-center justify-start lg:justify-end">
<BooleanToggle
className="w-full lg:w-40"
value={enabled}
onChange={(value) => void handleStatusToggle(value)}
trueLabel="启用"
falseLabel="停用"
ariaLabel={`${providerName}状态`}
disabled={saving || statusSaving}
/>
</div>
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<Pencil className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" />
</div>
<DialogTitle>{providerName}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<Pencil className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" />
</div>
<DialogTitle>{providerName}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) =>
field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)}
className={cn(
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)}
>
<span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />}
</button>
);
})}
<DialogBody className="flex-1 px-4 py-3">
<form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) =>
field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)}
className={cn(
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)}
>
<span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />}
</button>
);
})}
</div>
</div>
) : (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input
id={`${provider}-${field.key}`}
name={field.key}
type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/>
{field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p>
)}
</div>
),
)}
</div>
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
) : (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input
id={`${provider}-${field.key}`}
name={field.key}
type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/>
{field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p>
)}
<div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}>
{enabled ? "已启用" : "已停用"}
</StatusBadge>
</div>
),
)}
</div>
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
<div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}>
{enabled ? "已启用" : "已停用"}
</StatusBadge>
</div>
</div>
</div>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
</Button>
<Button type="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"}
</Button>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
</section>
{provider === "balance" && (
<Dialog open={balanceDisableOpen} onOpenChange={(nextOpen) => !statusSaving && setBalanceDisableOpen(nextOpen)}>
<DialogContent className="max-w-[22rem]">
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setBalanceDisableOpen(false)}
disabled={statusSaving}
>
</Button>
<Button type="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"}
<Button
type="button"
variant="destructive"
onClick={() => {
setBalanceDisableOpen(false);
void commitStatusToggle(false);
}}
disabled={statusSaving}
>
{statusSaving ? "处理中..." : "确认关闭"}
</Button>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
</section>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@@ -20,7 +20,9 @@ export async function getPaymentProviderConfigs() {
enabled: config.enabled,
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
}
: null,
: provider.id === "balance"
? { enabled: true, config: {} }
: null,
secretConfigured: configValue
? getPaymentSecretConfiguredState(provider.id, configValue)
: {},

View File

@@ -10,10 +10,12 @@ export const PLAN_BATCH_FORM_ID = "plan-batch-form";
export function PlansList({
plans,
activeCountMap,
reservedCardCountMap,
services,
}: {
plans: AdminPlanRow[];
activeCountMap: Map<string, number>;
reservedCardCountMap: Map<string, number>;
services: StreamingServiceOption[];
}) {
return (
@@ -31,6 +33,7 @@ export function PlansList({
key={plan.id}
plan={plan}
activeCount={activeCountMap.get(plan.id) ?? 0}
reservedCardCount={reservedCardCountMap.get(plan.id) ?? 0}
services={services}
batchFormId={PLAN_BATCH_FORM_ID}
/>

View File

@@ -23,6 +23,7 @@ export default async function PlansPage({
pageSize,
filters,
activeCountMap,
reservedCardCountMap,
serviceOptions,
} = await getAdminPlans(await searchParams);
@@ -60,6 +61,7 @@ export default async function PlansPage({
<PlansList
plans={plans}
activeCountMap={activeCountMap}
reservedCardCountMap={reservedCardCountMap}
services={serviceOptions}
/>
<Pagination total={total} pageSize={pageSize} page={page} />

View File

@@ -55,6 +55,7 @@ interface PlanListItem {
interface PlanCardProps {
plan: PlanListItem;
activeCount: number;
reservedCardCount: number;
services: StreamingServiceOption[];
batchFormId: string;
}
@@ -63,13 +64,13 @@ function toNumber(value: NumericLike): number | null {
return value == null ? null : Number(value);
}
function remainingStockSummary(plan: PlanListItem, activeCount: number) {
function remainingStockSummary(plan: PlanListItem, activeCount: number, reservedCardCount: number) {
if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
const remaining = Math.max(0, plan.totalLimit - activeCount);
const remaining = Math.max(0, plan.totalLimit - activeCount - reservedCardCount);
return {
value: remaining.toString(),
hint: remaining === 0 ? "已售罄" : "剩余库存",
hint: reservedCardCount > 0 ? `预占 ${reservedCardCount}` : remaining === 0 ? "已售罄" : "剩余库存",
empty: remaining === 0,
};
}
@@ -112,9 +113,9 @@ function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
};
}
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
export function PlanCard({ plan, activeCount, reservedCardCount, services, batchFormId }: PlanCardProps) {
const planFormValue = buildPlanFormValue(plan);
const stock = remainingStockSummary(plan, activeCount);
const stock = remainingStockSummary(plan, activeCount, reservedCardCount);
const Icon = plan.type === "PROXY" ? Network : Tv;
return (

View File

@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import { activePlanCardReservationWhere } from "@/services/plan-availability";
import type { StreamingServiceOption } from "./plan-form";
const planInclude = {
@@ -43,7 +44,7 @@ export async function getAdminPlans(
: {}),
} satisfies Prisma.SubscriptionPlanWhereInput;
const [plans, total, services, activeGroups] = await Promise.all([
const [plans, total, services, activeGroups, reservedCardGroups] = await Promise.all([
prisma.subscriptionPlan.findMany({
where,
include: planInclude,
@@ -62,11 +63,22 @@ export async function getAdminPlans(
where: { status: "ACTIVE" },
_count: { _all: true },
}),
prisma.rechargeCard.groupBy({
by: ["planId"],
where: {
...activePlanCardReservationWhere(),
planId: { not: null },
},
_count: { _all: true },
}),
]);
const activeCountMap = new Map(
activeGroups.map((item) => [item.planId, item._count._all]),
);
const reservedCardCountMap = new Map(
reservedCardGroups.map((item) => [item.planId ?? "", item._count._all]),
);
const serviceOptions: StreamingServiceOption[] = services.map((service) => ({
id: service.id,
name: service.name,
@@ -80,6 +92,7 @@ export async function getAdminPlans(
pageSize,
filters: { q, type, status },
activeCountMap,
reservedCardCountMap,
serviceOptions,
};
}