mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add wallet and recharge cards
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
: {},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user