fix: submit recharge card form

This commit is contained in:
JetSprow
2026-05-01 03:16:11 +10:00
parent 018bed3f36
commit 035ac9266a
6 changed files with 182 additions and 71 deletions

View File

@@ -22,6 +22,7 @@ export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
const router = useRouter();
const [type, setType] = useState<"BALANCE" | "PLAN">("BALANCE");
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
const [hasExpiry, setHasExpiry] = useState(false);
const [pending, startTransition] = useTransition();
const selectedPlan = plans.find((plan) => plan.id === planId) ?? null;
const planSoldOut = type === "PLAN" && selectedPlan?.remainingCount === 0;
@@ -40,6 +41,7 @@ export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
await createAdminRechargeCards(formData);
toast.success("充值卡已生成");
form.reset();
setHasExpiry(false);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成充值卡失败"));
@@ -93,12 +95,19 @@ export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
<Label></Label>
<Select value={planId} onValueChange={(value) => setPlanId(value ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择套餐" />
<SelectValue placeholder="选择套餐">
{(value) => {
const plan = plans.find((item) => item.id === value);
return plan
? `${plan.name} · ${plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}`
: "选择套餐";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} · {plan.type === "PROXY" ? "代理" : "流媒体"} · {plan.remainingCount ?? "不限"}
{plan.name} · {plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"} · {plan.remainingCount ?? "不限"}
</SelectItem>
))}
</SelectContent>
@@ -112,11 +121,35 @@ export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
)}
<div className="space-y-2">
<Label htmlFor="recharge-card-expires"></Label>
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" />
<Label htmlFor="recharge-card-expiry-mode"></Label>
<BooleanToggle
id="recharge-card-expiry-mode"
value={hasExpiry}
onChange={setHasExpiry}
trueLabel="设置到期"
falseLabel="永不过期"
ariaLabel="充值卡有效期"
/>
</div>
<Button className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
{hasExpiry && (
<div className="space-y-2">
<Label htmlFor="recharge-card-expires"></Label>
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" required />
</div>
)}
{!hasExpiry && (
<input type="hidden" name="expiresAt" value="" />
)}
{type === "PLAN" && plans.length === 0 && (
<div className="rounded-lg border border-destructive/15 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
</div>
)}
<Button type="submit" className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
{pending ? "生成中..." : planSoldOut ? "套餐已售罄" : "生成充值卡"}
</Button>
</form>

View File

@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import { getPlanAvailability } from "@/services/plan-availability";
function getRechargeCardPlanRemaining(
@@ -15,8 +16,11 @@ function getRechargeCardPlanRemaining(
return limits.length > 0 ? Math.min(...limits) : null;
}
export async function getCommerceData() {
const [coupons, promotions, rechargeCards, planRows] = await Promise.all([
export async function getCommerceData(
searchParams: Record<string, string | string[] | undefined> = {},
) {
const { page, skip, pageSize } = parsePage(searchParams, 20);
const [coupons, promotions, rechargeCards, rechargeCardTotal, planRows] = await Promise.all([
prisma.coupon.findMany({
orderBy: { createdAt: "desc" },
include: { _count: { select: { orders: true, grants: true } } },
@@ -32,8 +36,10 @@ export async function getCommerceData() {
plan: { select: { name: true, type: true } },
redeemedBy: { select: { email: true } },
},
take: 50,
skip,
take: pageSize,
}),
prisma.rechargeCard.count(),
prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
@@ -60,5 +66,11 @@ export async function getCommerceData() {
}),
);
return { coupons, promotions, rechargeCards, plans };
return {
coupons,
promotions,
rechargeCards,
rechargeCardPagination: { total: rechargeCardTotal, page, pageSize },
plans,
};
}

View File

@@ -4,6 +4,7 @@ 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 { Pagination } from "@/components/shared/pagination";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Input } from "@/components/ui/input";
@@ -28,8 +29,25 @@ export const metadata: Metadata = {
description: "管理优惠券与满减规则。",
};
export default async function AdminCommercePage() {
const { coupons, promotions, rechargeCards, plans } = await getCommerceData();
const rechargeCardStatusLabels: Record<string, string> = {
UNUSED: "未使用",
REDEEMED: "已兑换",
EXPIRED: "已过期",
DISABLED: "已停用",
};
function normalizeCommerceTab(value: string | string[] | undefined) {
return value === "manage" || value === "cards" ? value : "create";
}
export default async function AdminCommercePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const activeTab = normalizeCommerceTab(params.tab);
const { coupons, promotions, rechargeCards, rechargeCardPagination, plans } = await getCommerceData(params);
return (
<PageShell>
@@ -38,7 +56,7 @@ export default async function AdminCommercePage() {
title="优惠与奖励"
/>
<Tabs defaultValue="create" className="space-y-6">
<Tabs defaultValue={activeTab} className="space-y-6">
<TabsList variant="line" className="surface-card p-1">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="manage"></TabsTrigger>
@@ -185,7 +203,7 @@ export default async function AdminCommercePage() {
<RechargeCardForm plans={plans} />
<div className="space-y-4">
<SectionHeader title="最近充值卡" />
<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">
@@ -197,13 +215,7 @@ export default async function AdminCommercePage() {
<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"
? "已过期"
: "已停用"}
{rechargeCardStatusLabels[card.status] ?? "未知状态"}
</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
@@ -216,7 +228,7 @@ export default async function AdminCommercePage() {
<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>}
<StatusBadge>{card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"}</StatusBadge>
</div>
<div className="flex justify-start lg:justify-end">
<CopyButton text={card.code} />
@@ -227,6 +239,12 @@ export default async function AdminCommercePage() {
<p className="px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</div>
<Pagination
total={rechargeCardPagination.total}
pageSize={rechargeCardPagination.pageSize}
page={rechargeCardPagination.page}
fixedParams={{ tab: "cards" }}
/>
</div>
</section>
</TabsContent>